Integrating Payments Safely: Charges, Webhooks, and PCI Scope

Integrate a Stripe-style payment provider from server-side PHP without ever touching raw card data, verify webhook signatures before trusting asynchronous payment events, apply idempotency keys to charge operations, and reconcile your database against the provider's records.

Payments are the one part of a web application where a bug doesn't just produce a wrong page — it produces a wrong bank balance, a double-charged customer, or a security incident that lands your company in a compliance audit. The good news is that modern payment providers (Stripe, Braintree, Adyen, and similar) have deliberately designed their APIs so that you, the application developer, almost never need to see or handle a raw card number at all. The bad news is that the pieces you are still responsible for — creating charges correctly, verifying webhooks, avoiding double-charges on retry, and reconciling your own records against the provider's — are exactly the pieces where a shortcut can quietly cost real money or leak trust. This lesson walks through the server-side integration pattern end to end: how a charge actually gets created without your server ever holding a card number, why every webhook must be cryptographically verified before you act on it, how idempotency keys prevent a network retry from charging a customer twice, why periodic reconciliation exists even when everything 'should' be working via webhooks, and what PCI-DSS compliance scope actually means for a typical PHP application that outsources card handling to its provider.

Never Let Raw Card Data Touch Your Server

The single most important architectural decision in a payments integration is made before you write a line of PHP: the customer's card number, expiry date, and CVV should never be submitted to your own backend at all. Instead, the payment provider gives you a client-side widget — Stripe Elements, Stripe.js, or an equivalent hosted field from another provider — that renders the actual card input fields inside an iframe served from the provider's own domain. When the customer submits the form, the card data goes directly from the browser to the provider's servers over TLS, and what comes back to your JavaScript is not the card number but an opaque token (a 'payment method ID' or 'source' identifier) representing that card. Your PHP backend only ever sees this token, never the underlying digits.

This matters for two reasons. First, security: if your server never receives raw card data, an attacker who compromises your application code cannot exfiltrate card numbers, because there simply aren't any there to steal. Second, and just as important practically, compliance: PCI-DSS (the Payment Card Industry Data Security Standard) imposes dramatically different obligations depending on whether your systems ever 'touch' cardholder data. An integration that uses tokenized, provider-hosted fields typically qualifies for the simplest self-assessment questionnaire (SAQ A), because you are attesting that cardholder data never enters your environment at all — whereas an integration where raw card numbers pass through your own server or JavaScript puts you in a far heavier compliance category with network segmentation, quarterly vulnerability scans, and a much longer audit checklist.

The server-side flow, then, is: the browser obtains a token from the provider's client library; your PHP backend receives that token (never a card number) in the request body; your backend calls the provider's API — using your secret API key, which must never be exposed to the browser — to create a PaymentIntent or Charge object using that token; the provider handles the actual authorization against the card network and tells you (synchronously in the API response, and again asynchronously via webhook) whether it succeeded.

• Card fields render inside the provider's own iframe (Stripe Elements or equivalent) — card data never reaches your JavaScript or your server. • Your backend only ever handles an opaque token/payment-method ID, never a PAN (primary account number) or CVV. • Your secret API key lives only on the server; a 'publishable' key is the only credential the browser ever sees. • Tokenized integrations typically qualify for PCI-DSS SAQ A, the lightest self-assessment tier — handling raw card data yourself pushes you into a much heavier compliance category.

Creating a Charge Server-Side

With a token in hand, your PHP backend creates the actual charge (in modern Stripe-style APIs, a 'PaymentIntent') by making an authenticated server-to-server API call. This call includes the amount (always in the smallest currency unit — cents, not dollars, to avoid floating-point rounding errors), the currency, the payment method token, and typically a reference back to your own order ID in a metadata field so you can correlate the provider's records with yours later. The provider's API responds synchronously with the result of that specific call: success, a decline, or a requirement for additional authentication (3D Secure/SCA challenges are common in Europe under PSD2 regulation).

It's tempting to treat that synchronous API response as the final word on whether the payment succeeded, but for many payment methods and flows, the response you get back is only the *first* signal. Some payment methods settle asynchronously (bank transfers, some 3D-Secure flows, many non-card methods), meaning the definitive 'this payment actually succeeded' event arrives later via webhook, not in the initial API response. This is why serious payment integrations treat the webhook — not the initial API call's response — as the authoritative source of truth for whether an order should actually be marked paid and fulfilled.

A practical implication: your order should typically start in a 'pending' or 'awaiting payment' state as soon as the charge is created, and only flip to 'paid' once the corresponding payment_intent.succeeded (or equivalent) webhook arrives and is verified. Fulfilling an order (shipping a product, granting access to a course) purely on the strength of the synchronous API response, without waiting for webhook confirmation, is a common mistake that leads to fulfilling orders that later turn out to have failed or been disputed.

• Always express amounts in the smallest currency unit (cents) to avoid floating-point rounding bugs — $19.99 is amount: 1999, not 19.99. • Attach your own order ID as metadata on the charge/PaymentIntent so provider records can be correlated back to your database. • The synchronous API response is not always the final word — some methods settle asynchronously and the webhook is authoritative. • Keep orders 'pending' until a verified webhook confirms success; don't fulfill purely off the initial API call's response.

Webhooks: Why Signature Verification Is Non-Negotiable

A webhook is the payment provider calling *your* server: an HTTP POST to a URL you register, carrying a JSON payload describing an event like 'payment succeeded', 'payment failed', 'dispute opened', or 'subscription renewed'. Webhooks exist because payments are inherently asynchronous — the provider needs a way to tell you about events that happen after the original API call returns, sometimes seconds later, sometimes days later (a dispute, for instance, can be filed weeks after the original charge).

Here is the critical security problem: your webhook endpoint is a public URL. Anyone on the internet can send a POST request to it with a JSON body that says {"type": "payment_intent.succeeded", ...}. If your code trusted that payload at face value, an attacker could simply forge a fake 'payment succeeded' event for an order they never actually paid for, and your application would happily mark the order paid and ship the product. This is not a theoretical risk — unverified webhook endpoints are a well-known attack vector precisely because the payoff (free goods, unlocked features) is so direct.

The fix is that every payment provider signs its webhook payloads using a shared secret known only to your account (the 'webhook signing secret', obtained from the provider's dashboard when you register the endpoint) and includes that signature in a request header. Your endpoint's very first job — before parsing the JSON, before touching the database, before any business logic — is to recompute the expected signature from the raw request body and your signing secret, and compare it to the signature the provider sent. Only if they match do you proceed to process the event. If they don't match, you reject the request outright (typically with a 400 response) and log the attempt.

It is important to verify against the *raw* request body bytes, not a re-serialized version of the parsed JSON — re-encoding JSON can change whitespace or key ordering in ways that would break the signature even for a legitimate request, so frameworks and libraries always read the body as a raw string first, verify, and only then decode it as JSON.

• A webhook is the provider POSTing an event to your server — and that URL is publicly reachable by anyone. • Without verification, an attacker can forge a fake 'payment succeeded' payload and trick your app into fulfilling an unpaid order. • Verification: recompute the expected signature from the raw request body + your signing secret, and compare it to the header the provider sent — before any other processing. • Always verify against the raw request bytes, never a re-serialized/re-encoded version of the parsed JSON. • Reject unverified requests with an error response and log the attempt; never process the event's contents first and validate later.

Idempotency: Preventing Double-Charges on Retry

Networks fail. A client can send a 'create charge' request, the provider can successfully create and process the charge, and then the response can be lost in transit — a timeout, a dropped connection, a proxy restart. From the calling code's point of view, the request appears to have failed, and naive retry logic will simply send the same charge request again. Without protection, this creates a second, entirely separate charge against the customer's card for the same order: a real double-charge, with real money moved twice.

This is exactly the idempotency-key pattern covered earlier in this course applied to its highest-stakes use case. When creating a charge, you generate a unique idempotency key — commonly derived from your own order ID, since a given order should only ever be charged once — and pass it to the provider's API alongside the charge request. If the same idempotency key is sent again (because your code retried after a timeout), the provider recognizes it has already processed a request with that exact key and returns the original result instead of creating a second charge. The customer is charged exactly once, no matter how many times the retry logic fires.

The idempotency key should be generated once per logical operation and reused across retries of that same operation — not regenerated on every attempt, which would defeat the entire purpose. A common pattern is to compute the key deterministically from data you already have, such as hash('sha256', "charge:{$orderId}"), so that even a fresh process retrying after a crash derives the identical key rather than needing to persist and look one up.

• A lost response after a successful charge looks like a failure to your code — naive retries then double-charge the customer. • An idempotency key sent with the charge request lets the provider recognize a retried request and return the original result instead of creating a duplicate charge. • Generate the key once per logical operation (e.g. per order) and reuse it across retries — never generate a fresh key on every attempt. • Deriving the key deterministically from your own order ID (rather than storing a random one) survives even a process crash between attempts.

Reconciliation: Trust, but Verify

Even with signature-verified webhooks and idempotent charges, real systems drift. A webhook delivery can be delayed indefinitely by an outage on either side; your endpoint can throw an unhandled exception during a deploy at the exact moment a webhook arrives; a provider incident can silently drop a batch of events. Relying on webhooks as the *only* mechanism for keeping your database in sync with the provider's actual payment records means any of these gaps quietly becomes a permanent discrepancy — an order marked unpaid that the provider actually processed successfully, or vice versa.

Reconciliation is the practice of periodically (typically via a scheduled job, nightly or more often for high-volume systems) querying the provider's API for a list of actual payment records over some time window, and comparing that list against your own database's payment records for the same window. Any payment that exists in the provider's records but not in yours (a missed webhook) gets backfilled; any payment your database thinks succeeded but the provider's records don't confirm gets flagged for investigation. Most providers also expose a full event log or a 'balance transactions' endpoint specifically to support this kind of after-the-fact reconciliation.

Beyond catching missed webhooks, reconciliation is also how organizations detect and investigate refunds, disputes, and currency conversion fees that may not map one-to-one to how your application models 'a payment' — a mature payments integration budgets ongoing engineering time for reconciliation tooling, not just the initial charge-and-webhook happy path.

• Webhooks can be delayed, dropped, or missed due to outages on either side — they are not a 100% reliable channel by themselves. • Reconciliation: periodically fetch the provider's actual payment records and diff them against your database to catch drift. • Backfill missed webhooks; flag payments your database believes happened but the provider doesn't confirm. • Also surfaces refunds, disputes, and fees that don't map neatly onto your application's simpler payment model.

Minimizing Sensitive Data: Tokens, Not PANs

The rule for stored payment data is simple and absolute: never store a raw primary account number (PAN) or CVV in your own database, logs, or backups — ever, under any circumstances, even 'temporarily' or 'for debugging'. What you store instead is the provider-issued token (a 'customer ID' and/or 'payment method ID') that lets you ask the provider to charge that card again later, without your systems ever holding the actual card number. This is the entire point of tokenization: the token is useless to an attacker who steals your database, because it only works when combined with your provider account's own API credentials, and it can typically be revoked or scoped.

This principle extends to logging: it is a genuine, recurring incident category for a card number to end up in a plaintext application log because a developer logged an entire request payload during debugging without realizing a raw card field was present in it (this shouldn't happen if you're using client-side tokenization correctly, but defense in depth means scrubbing or refusing to log any field that looks like a PAN or CVV, as a safety net). The same caution applies to error-tracking tools (Sentry, Bugsnag, etc.) — configure them to scrub known-sensitive field names before a payload is ever transmitted off your server.

Reducing what sensitive data you handle is also what keeps PCI-DSS scope manageable: every system that stores, processes, or transmits cardholder data falls inside the compliance boundary and must meet its controls (encryption at rest, network segmentation, quarterly scans, restricted access, and more). By architecting the integration so that cardholder data never enters your systems at all — only provider tokens do — you keep your entire application outside that boundary for card data purposes, which is a substantial ongoing cost and audit burden avoided, not just a one-time convenience.

• Never store a raw PAN or CVV anywhere — database, logs, backups, error trackers — under any circumstances. • Store only provider-issued tokens (customer ID / payment method ID); they are useless to an attacker without your account's own API credentials. • Scrub sensitive-looking fields from logs and error-tracking tools as defense in depth, even when the primary flow shouldn't ever see raw card data. • Keeping cardholder data entirely out of your systems keeps your whole application out of the heaviest PCI-DSS scope tiers.

Verifying a Stripe-Style Webhook Signature

Always read the body with php://input (raw bytes) — never rely on a framework's already-parsed request object for signature verification. The webhook signing secret is separate from your API secret key and is issued per registered endpoint.

<?php
declare(strict_types=1);

$payload = file_get_contents('php://input'); // raw bytes, not parsed JSON
$signatureHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$webhookSecret = getenv('STRIPE_WEBHOOK_SECRET');

[$timestamp, $expectedSig] = verifyWebhookSignature($payload, $signatureHeader, $webhookSecret);
$event = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);

Code Examples

Signature verification happens before any business logic runs: the raw body and header are used to recompute an HMAC-SHA256 signature, hash_equals() does a constant-time comparison to avoid timing attacks, and a timestamp tolerance window guards against replaying an old, legitimately-signed payload. Only after all of that passes does the code trust event['type'] and act on it.

if (!hash_equals($expected, $signature)) { throw new RuntimeException('Signature verification failed'); }

The idempotency key is derived deterministically from the order ID rather than generated randomly, so a retry after a crash or timeout — even from a fresh PHP process with no memory of the earlier attempt — produces the exact same key and safely collapses into the original charge instead of creating a duplicate. amount is 1999 (cents) for a $19.99 charge — payment APIs almost universally use integer minor units to avoid floating-point rounding errors on money.

<?php
declare(strict_types=1);

function verifyStripeSignature(string $payload, string $header, string $secret): array
{
    // Header looks like: t=1699999999,v1=abcdef123...
    $parts = [];
    foreach (explode(',', $header) as $piece) {
        [$key, $value] = array_pad(explode('=', $piece, 2), 2, null);
        $parts[$key] = $value;
    }

    $timestamp = $parts['t'] ?? null;
    $signature = $parts['v1'] ?? null;

    if ($timestamp === null || $signature === null) {
        throw new RuntimeException('Malformed signature header');
    }

    // Reject old requests to prevent replay attacks (5 minute tolerance)
    if (abs(time() - (int) $timestamp) > 300) {
        throw new RuntimeException('Webhook timestamp too old');
    }

    $signedPayload = $timestamp . '.' . $payload;
    $expected = hash_hmac('sha256', $signedPayload, $secret);

    // hash_equals prevents timing-attack leakage of the correct signature
    if (!hash_equals($expected, $signature)) {
        throw new RuntimeException('Signature verification failed');
    }

    return ['timestamp' => (int) $timestamp];
}

$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? 
…
<?php
declare(strict_types=1);

function createCharge(string $paymentMethodToken, int $amountCents, string $orderId): array
{
    $idempotencyKey = hash('sha256', "charge:{$orderId}");

    $ch = curl_init('https://api.stripe.com/v1/payment_intents');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('STRIPE_SECRET_KEY'),
            'Idempotency-Key: ' . $idempotencyKey,
            'Content-Type: application/x-www-form-urlencoded',
        ],
        CURLOPT_POSTFIELDS => http_build_query([
            'amount' => $amountCents,       // smallest unit: cents, never dollars
            'currency' => 'usd',
            'payment_method' => $paymentMethodToken,
            'confirm' => 'true',
            'metadata[order_id]' => $orderId,
        ]),
    ]);

    $response = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($status >= 400) {
        throw new RuntimeException("Payment provider error (HTTP {$status}): {$response}");
    }

    return json_decode($response, true, flags: JSON_THROW_ON_ERROR);
}

//
…

Payment Flow: Client, Server, Provider

Card data flows browser→provider directly; your server only ever sees tokens and signed webhook events.

Browser                 Your PHP Server              Payment Provider
 ┌──────────┐  token only ┌───────────────┐  charge+key ┌───────────────┐
 │ Stripe.js │ ───────────▶│ create charge  │ ───────────▶│ authorize card │
 │ (card in  │             │ (idempotency   │             │ (never touches │
 │  iframe)  │             │  key = order)  │             │  your server)  │
 └──────────┘             └───────┬───────┘             └───────┬───────┘
                                   │  webhook (signed)            │
                                   ◀───────────────────────────────
                          verify signature → mark order paid

Important Rules

Never let raw card numbers or CVVs reach your own server, logs, or database — use client-side tokenization.

Verify every webhook's signature against the raw request body before parsing or acting on its contents.

Treat a verified webhook, not the initial API call's response, as the authoritative signal for order fulfillment when the payment method can settle asynchronously.

Attach a deterministic idempotency key (derived from your order ID) to every charge-creation request.

Run scheduled reconciliation against the provider's actual records — do not rely on webhooks as your only source of truth.

Why Reconciliation Jobs Exist at Every Payments Company

Companies processing significant payment volume — from small SaaS billing systems to large marketplaces — universally run a nightly (or more frequent) reconciliation job that pulls a provider's full transaction list for the period and diffs it against internal records, because even a well-built webhook pipeline experiences occasional delivery gaps during deploys, provider incidents, or network partitions, and unnoticed drift eventually surfaces as a support ticket or, worse, a silent revenue leak.

$event = json_decode(file_get_contents('php://input'), true); if ($event['type'] === 'payment_intent.succeeded') { markOrderPaid(...); } verifyStripeSignature($payload, $header, $secret); // throws on mismatch $event = json_decode($payload, true, flags: JSON_THROW_ON_ERROR); if ($event['type'] === 'payment_intent.succeeded') { markOrderPaid(...); }

Acting on a webhook's contents without verifying its signature first means anyone who knows (or guesses) your endpoint URL can POST a forged 'payment succeeded' payload and get an order marked paid for free.

$key = uniqid('charge_', true); // regenerated fresh on every retry attempt

$key = hash('sha256', "charge:{$orderId}");

An idempotency key that changes on every retry defeats its own purpose — the provider only recognizes a retry as a duplicate if the exact same key is sent again, so the key must be derived deterministically from something stable like the order ID.

Why should your PHP backend generally never receive a raw card number, even briefly? A. Raw card numbers are too large to fit in a normal HTTP request B. Client-side tokenization keeps card data out of your systems entirely, reducing both breach risk and PCI-DSS compliance scope C. PHP cannot store string data longer than 16 characters D. Card numbers must always be encrypted with a different algorithm than PHP supports

B. Client-side tokenization keeps card data out of your systems entirely, reducing both breach risk and PCI-DSS compliance scope — Using the provider's hosted/tokenized card fields means cardholder data never enters your environment, which both eliminates a major breach target and keeps you in the lightest PCI-DSS compliance tier (like SAQ A).

What is the primary danger of an unverified webhook endpoint? A. It will be slower to respond than a verified one B. An attacker can POST a forged event payload (e.g. a fake 'payment succeeded') and trick your app into acting on it C. The provider will stop sending webhooks after a few failures D. PHP will refuse to parse JSON without a valid signature

B. An attacker can POST a forged event payload (e.g. a fake 'payment succeeded') and trick your app into acting on it — Webhook endpoints are public URLs; without signature verification, anyone can send a fabricated event payload and your application has no way to distinguish it from a genuine one.

Why should signature verification use the raw request body rather than a re-serialized version of the parsed JSON? A. Raw bytes are always shorter and faster to hash B. Re-encoding JSON can change whitespace or key order, which would break the signature comparison even for a legitimate request C. PHP cannot compute HMACs over decoded arrays D. The provider only signs the first 100 bytes of the payload

B. Re-encoding JSON can change whitespace or key order, which would break the signature comparison even for a legitimate request — The signature was computed by the provider over the exact bytes it sent; any re-serialization of the parsed JSON is not guaranteed to produce identical bytes, so verification must happen against the untouched raw body.

What problem does an idempotency key solve when creating a charge? A. It makes the charge process asynchronously in the background B. It prevents a retried request (e.g. after a network timeout) from creating a second, duplicate charge for the same operation C. It encrypts the card token before sending it to the provider D. It automatically converts the charge amount to the customer's local currency

B. It prevents a retried request (e.g. after a network timeout) from creating a second, duplicate charge for the same operation — If a client retries a charge-creation request after losing the original response, an idempotency key lets the provider recognize the retry and return the original result instead of billing the customer twice.

Why run a reconciliation job even when webhook signature verification and idempotency keys are both correctly implemented? A. Reconciliation replaces the need for webhooks entirely B. Webhooks can still be delayed or dropped due to outages on either side, so periodic comparison against the provider's actual records catches drift that webhooks alone would miss C. Idempotency keys expire after 24 hours and must be renewed via reconciliation D. PCI-DSS legally requires reconciliation regardless of architecture

B. Webhooks can still be delayed or dropped due to outages on either side, so periodic comparison against the provider's actual records catches drift that webhooks alone would miss — Signature verification and idempotency solve correctness for individual requests, but they don't guarantee every webhook is ever delivered — reconciliation is the safety net that catches missed events and other drift.

Write a PHP function verifyAndParseWebhook(string $rawBody, string $signatureHeader, string $secret): array that recomputes an HMAC-SHA256 signature over the raw body using the secret, compares it with hash_equals() to the signature extracted from the header, throws a RuntimeException on mismatch, and otherwise returns the decoded JSON event array.

<?php function verifyAndParseWebhook(string $rawBody, string $signatureHeader, string $secret): array { $parts = []; foreach (explode(',', $signatureHeader) as $piece) { [$k, $v] = array_pad(explode('=', $piece, 2), 2, null); $parts[$k] = $v; } $timestamp = $parts['t'] ?? throw new RuntimeException('Missing timestamp'); $signature = $parts['v1'] ?? throw new RuntimeException('Missing signature'); $expected = hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret); if (!hash_equals($expected, $signature)) { throw new RuntimeException('Invalid webhook signature'); } return json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR); }

Extract the signature from the header format 't=...,v1=...', build the signed payload as "{$timestamp}.{$rawBody}", and use hash_hmac('sha256', ...) plus hash_equals() for the comparison.