> ## Documentation Index
> Fetch the complete documentation index at: https://docs.quidkey.com/llms.txt
> Use this file to discover all available pages before exploring further.

# After Payment

> Handle webhooks, verify signatures, process fees, and go live

After a customer completes a payment, Quidkey delivers the result to your backend via an HTTPS webhook. This guide covers webhook setup, signature verification, fee processing, and a production QA checklist.

## Redirect Handling

After bank authentication, the customer is redirected to the URL you specified when [creating the payment request](/guides/embedded-flow/create):

* **Success**: redirected to `success_url`
* **Failure / Cancel**: redirected to `failure_url`

<Warning>
  Do not rely solely on the redirect to confirm payment. Always verify payment status via the webhook. Redirects can fail or be interrupted.
</Warning>

## Webhook Setup

Quidkey supports two delivery models:

* **Default webhook**: a single per-merchant URL, set via the API below and signed with your merchant secret. It is used for every payment that does not name an endpoint.
* **Named webhook endpoints**: multiple destinations, each with its own signing secret and an optional test/live scope. You register them in the Console and route each payment to one or more of them by name. See [Named Webhook Endpoints](#named-webhook-endpoints).

### Default Webhook: Register and Obtain a Signing Secret

<Steps>
  <Step title="Register your webhook URL">
    ```bash theme={null}
    curl -X POST 'https://core.quidkey.com/api/v1/webhooks' \
      -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
      -H 'Content-Type: application/json' \
      -d '{
        "webhook_url": "https://api.yoursite.com/webhooks/quidkey"
      }'
    ```

    The response confirms the URL has been registered.
  </Step>

  <Step title="Generate the signing secret">
    ```bash theme={null}
    curl -X POST 'https://core.quidkey.com/api/v1/webhooks/secret' \
      -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
    ```

    <Warning>
      The secret is returned **once**. Store it safely in a secure vault (AWS Secrets Manager, HashiCorp Vault, etc.).
    </Warning>

    <Tip>
      See the [Generate Webhook Secret API](/api-reference/webhook/generate-a-webhook-signing-secret-for-the-authenticated-merchant) for complete details and interactive playground.
    </Tip>
  </Step>

  <Step title="Revoke the secret (if needed)">
    Roll or revoke the secret during incident response:

    ```bash theme={null}
    curl -X POST 'https://core.quidkey.com/api/v1/webhooks/secret/revoke' \
      -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
    ```
  </Step>
</Steps>

### Named Webhook Endpoints

Platforms that create payments on behalf of several businesses (for example, a travel platform handling payments for multiple airlines) can register **multiple** webhook endpoints, each with its own URL and signing secret, then route each payment to the right one. Endpoints are managed in the Console, not the API.

<Steps>
  <Step title="Open Webhooks settings">
    In the [Console](https://console.quidkey.com), switch into the relevant **merchant** context, then open **Settings → Webhooks**.
  </Step>

  <Step title="Add an endpoint">
    Select **Add endpoint** and provide:

    * **Name**: a stable, unique identifier you reference per payment (for example `wizzair-prod`). It cannot be changed after creation.
    * **URL**: the HTTPS or HTTP destination. Private, loopback, link-local, and metadata addresses are rejected.
    * **Signed**: leave enabled to receive an `X-Signature` header (recommended). Disable only for a destination that cannot verify signatures.
    * **Environment**: choose **Test**, **Live**, or **Both** (default). A test-only endpoint receives webhooks only for test payments, and a live-only endpoint only for live payments, so you can validate an integration against a test receiver without it ever seeing live traffic.
  </Step>

  <Step title="Copy the signing secret">
    A `whsec_...` secret is generated and shown **once**. Copy it immediately and share it with the destination out of band.

    <Warning>
      The secret is revealed only at creation and on rotation. If it is lost, rotate to issue a new one.
    </Warning>
  </Step>

  <Step title="Rotate or disable later">
    * **Rotate secret** issues a new secret and shows it once. The previous secret stops verifying **immediately** (there is no grace window), so coordinate rotation with the destination.
    * **Disable** stops new deliveries to the endpoint. Endpoints are disabled, never deleted, so a payment that already references one always resolves.
  </Step>
</Steps>

#### Route a payment to one or more endpoints

Reference endpoints by name when you [create a payment request](/guides/embedded-flow/create), using the optional `webhook_endpoints` field. You can name up to **10** endpoints, and the payment's webhook is **fanned out** to every one of them (for example, the downstream business *and* your own platform receiver):

```json theme={null}
{ "webhook_endpoints": ["wizzair-prod", "tryp-platform"] }
```

Each endpoint receives its own delivery, signed with **that endpoint's** secret (an unsigned endpoint receives no `X-Signature`). A reference that is unknown, disabled, not owned by you, or whose test/live scope does not match the payment is rejected at creation with `400 UNKNOWN_WEBHOOK_ENDPOINT`. The payment is not created, so fix the reference and retry.

#### Resolution order

When a payment does **not** name an endpoint, Quidkey resolves the destination in order:

1. The default `webhook_url`, if set (signed with the merchant secret). Unchanged from today, so existing integrations are never re-routed.
2. Otherwise, the merchant's **default endpoint**, if one is marked as default in the Console (signed with its own secret).
3. Otherwise, if the merchant has exactly **one** active endpoint, that endpoint.
4. Otherwise (no default URL, no default endpoint, and zero or multiple active endpoints) the delivery is recorded as `FAILED`. Quidkey never guesses between endpoints; mark a default endpoint, or name one per payment, to avoid this.

A payment that names endpoints always uses them. For each named endpoint that is disabled, or whose test/live scope no longer matches the payment, that delivery is recorded as `FAILED` while the others still deliver; there is no fallback to the default.

### Webhook Payload

Quidkey sends a Stripe-style envelope so existing tooling can be reused. The example below is the **default webhook** payload. Deliveries to a **named endpoint** use the same shape, except that `bank_name` is omitted from `metadata`.

```json theme={null}
{
  "id": "evt_28b2d68f",
  "object": "event",
  "created": 1716148300,
  "type": "quidkey.payment_request.succeeded",
  "data": {
    "object": {
      "id": "pr_782516093",
      "amount": 2550,
      "currency": "EUR",
      "status": "succeeded",
      "test": true,
      "metadata": {
        "order_id": "ORD-123",
        "payment_token": "ptok_..."
      },
      "fees": {
        "total_fees": 2.50,
        "fees_currency": "EUR",
        "fees_breakdown": [
          {
            "id": "fee_percentage_123",
            "type": "percentage",
            "amount": 1.50,
            "currency": "EUR",
            "rate_type": "domestic_percent_fee",
            "rate_value": 1.5,
            "notes": "1.5% fee on 25.50 EUR"
          }
        ]
      }
    }
  }
}
```

<Note>
  **Fee information** is only included for successful payments (`status: "succeeded"`). Failed or cancelled payments do not include fees.
</Note>

### HTTP Headers

| Header        | Purpose                     |
| ------------- | --------------------------- |
| `X-Signature` | `t=<unix-ts>,v1=<hex-hmac>` |
| `X-Timestamp` | Unix epoch seconds          |
| `X-Client-Id` | Your `client_id`            |

The HMAC is SHA-256 over `"${timestamp}.${raw_body}"`, keyed by your **webhook signing secret**. For a payment routed to a named endpoint, the key is **that endpoint's** secret; an endpoint marked unsigned sends no `X-Signature` header.

### Verify Signatures

The `X-Signature` header lets you confirm that the webhook came from Quidkey and that the payload was not tampered with.

<Tabs>
  <Tab title="Using Stripe Helper">
    ```typescript theme={null}
    const sig = req.get('x-signature');
    const event = stripe.webhooks.constructEvent(
      req.rawBody,
      sig,
      process.env.QUIDKEY_WEBHOOK_SECRET
    );
    ```
  </Tab>

  <Tab title="Custom Implementation">
    ```typescript theme={null}
    import crypto from 'crypto';

    function verify(rawBody: Buffer, header: string, secret: string) {
      const [, ts, v1] = header.match(/^t=(\d+),v1=(.+)$/) || [];
      const hmac = crypto
        .createHmac('sha256', secret)
        .update(`${ts}.${rawBody}`)
        .digest('hex');

      if (hmac !== v1) throw new Error('Invalid signature');
      // Optional: check timestamp tolerance (5 min)
    }
    ```
  </Tab>
</Tabs>

### Process Webhook Events

```typescript theme={null}
app.post('/webhooks/quidkey', (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.headers['x-signature'],
    webhookSecret
  );

  if (event.type === 'quidkey.payment_request.succeeded') {
    const payment = event.data.object;

    // Store payment details
    await updatePayment(payment.metadata.order_id, {
      status: payment.status,
      amount: payment.amount,
      currency: payment.currency
    });

    // Process fee information if present
    if (payment.fees) {
      await storeFeeInformation({
        orderId: payment.metadata.order_id,
        totalFees: payment.fees.total_fees,
        feesCurrency: payment.fees.fees_currency,
        feeBreakdown: payment.fees.fees_breakdown
      });
    }

    // Process reward information if present
    if (payment.rewards) {
      await distributeRewards({
        orderId: payment.metadata.order_id,
        extraRewards: payment.rewards.extra_rewards,
        totalRewards: payment.rewards.total_rewards
      });
    }
  }

  res.status(200).send('OK');
});
```

<Info>
  **Delivery and recovery:** Each event is delivered with a single attempt (15-second timeout). There is **no automatic retry** today: a failed delivery is recorded and can be **manually resent by Quidkey** on request. Reconcile important payments against the API as a backstop, and de-duplicate using the top-level `id` field (a resend reuses it). Durable automatic retries are planned.
</Info>

## Fee Handling

Quidkey automatically calculates and applies fees for successful transactions. Fee information is included in webhook payloads for merchant accounting and billing reconciliation.

**Fee types:**

* **Percentage fees**: based on transaction amount (e.g., 1.5% of €100 = €1.50)
* **Fixed fees**: flat rate per transaction (e.g., €1.00 per transaction)
* **Currency-specific**: fees are calculated in the same currency as the transaction

Each fee in the `fees_breakdown` array contains:

```json theme={null}
{
  "id": "fee_unique_identifier",
  "type": "percentage",
  "amount": 1.50,
  "currency": "EUR",
  "rate_type": "domestic_percent_fee",
  "rate_value": 1.5,
  "notes": "1.5% fee on 25.50 EUR"
}
```

## QA Checklist Before Going Live

<AccordionGroup>
  <Accordion title="Security & Configuration">
    * [ ] Serve checkout over HTTPS (wallets such as Apple Pay require it)
    * [ ] Use live Stripe keys in production mode (if using Stripe alongside)
    * [ ] Ask Quidkey for your production merchant\_id and iframe URL
    * [ ] Store webhook secret in secure vault
  </Accordion>

  <Accordion title="User Experience">
    * [ ] Verify that only one payment method can be selected at any time
    * [ ] Confirm purchase button enables/disables correctly with bank selection
    * [ ] Test post-purchase redirect flows for success and failure
    * [ ] Verify dynamic height adjustments work smoothly
  </Accordion>

  <Accordion title="Payment Updates">
    * [ ] Verify amount updates work before payment initiation
    * [ ] Confirm updates are blocked after customer selects a bank
    * [ ] Test error handling for expired tokens
    * [ ] Ensure iframe refreshes correctly after updates
  </Accordion>

  <Accordion title="Webhooks & Fees">
    * [ ] Verify webhook signature validation works
    * [ ] De-duplicate events by `id` and confirm your recovery path (manual resend) for failed deliveries
    * [ ] Verify successful payments include fee information
    * [ ] Ensure your system correctly stores fee breakdown data
  </Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="Embedded Flow Overview" icon="credit-card" href="/guides/embedded-flow/overview">
    Back to the Embedded Flow overview
  </Card>

  <Card title="Embed the Checkout" icon="browser" href="/guides/embedded-flow/embed">
    Iframe setup, Stripe mutual exclusion, and purchase button routing
  </Card>

  <Card title="Webhook API Reference" icon="webhook" href="/api-reference/webhook/register-or-update-a-merchant-webhook-url">
    Full webhook endpoint documentation
  </Card>

  <Card title="Payment Links" icon="link" href="/guides/payment-links/overview">
    Collect payments via a hosted checkout page instead
  </Card>
</CardGroup>
