Skip to main content
Webhooks are how your backend learns the outcome of a payment. When a payment changes state, Quidkey delivers a signed event to your registered URL. This is the source of truth: never rely on a browser redirect to confirm a payment.
Redirects can fail, be interrupted, or be triggered by a customer who never paid. Always confirm payment via the webhook (or the status endpoint), not the redirect.

Register Your Endpoint

1

Register your webhook URL

Tell Quidkey where to deliver events.
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"
  }'
2

Generate a signing secret

Generate the secret used to sign every event.
curl -X POST 'https://core.quidkey.com/api/v1/webhooks/secret' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
The response returns the secret:
{
  "success": true,
  "data": { "webhook_secret": "whsec_4eC39HqLyjWDarjtT1zdp7dc..." }
}
The webhook_secret is returned once. Store it immediately in a secure vault (AWS Secrets Manager, HashiCorp Vault, etc.). If you lose it, generate a new one.
3

Revoke the secret (if needed)

Roll or revoke the secret during incident response. Generate a new one afterwards.
curl -X POST 'https://core.quidkey.com/api/v1/webhooks/secret/revoke' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'

Event Payload

Quidkey sends a Stripe-style Event envelope, so existing Stripe tooling can be reused. The payment object lives at data.object.
{
  "id": "evt_9f8b2c14-3d6a-4e21-bb02-7c1d9a4e5f60",
  "object": "event",
  "created": 1716148300,
  "type": "quidkey.payment_request.succeeded",
  "data": {
    "object": {
      "id": "4a7b1e2c-9d83-4f10-a6b5-2e9c7d041f8a",
      "amount": "1999",
      "currency": "GBP",
      "status": "completed",
      "test": false,
      "metadata": {
        "order_id": "ORD-123",
        "payment_token": "ptok_...",
        "bank_name": "Monzo"
      },
      "fees": {
        "total_fees": "30",
        "fees_currency": "GBP",
        "fees_breakdown": [
          {
            "id": "c1f0a9e7-5b62-4d38-9a14-8e3b6c2d70f5",
            "type": "percentage",
            "amount": "30",
            "currency": "GBP",
            "rate_type": "domestic_percent_fee",
            "rate_value": "1.5",
            "notes": "1.5% fee on 1999 GBP (minor units)"
          }
        ]
      }
    }
  }
}
On the wire, amount is a string holding a stringified integer of minor units ("1999" = £19.99), and the fees numeric fields (total_fees, each fee’s amount, rate_value) are strings too. Parse them before doing arithmetic.
FieldDescription
idUnique event ID (evt_...). Use this to de-duplicate: see below.
objectAlways event.
createdEvent creation time, Unix epoch seconds.
typeThe event type. See the catalog.
data.object.idThe payment request ID.
data.object.amountAmount in minor units, sent as a stringified integer ("1999" = £19.99). See Amounts & Currencies.
data.object.currencyISO 4217 currency code.
data.object.statusUnderlying payment status.
data.object.testtrue for sandbox payments. See Testing.
data.object.metadataIncludes order_id, payment_token, and bank_name.
data.object.feesFee breakdown. Included only on succeeded events.

Verify Every Event

Quidkey signs each delivery with an HMAC. You must verify the signature before trusting or parsing the payload, otherwise anyone who discovers your URL could forge events.

Signature Headers

Each request includes these headers:
HeaderDescription
Stripe-Signaturet=<unix-ts>,v1=<hex-hmac>
X-SignatureSame value as Stripe-Signature (use either)
X-TimestampUnix epoch seconds (the t value)
X-Client-IdYour client_id
Stripe-Signature and X-Signature carry the identical value. Use Stripe-Signature with the Stripe SDK, or either header with a custom verifier.

How the Signature Is Computed

The signature is an HMAC SHA-256 over the string `${timestamp}.${rawBody}` (the timestamp, a literal dot, then the raw request body), keyed by your webhook signing secret.
The HMAC key is the full secret, including the whsec_ prefix, used verbatim. Do not strip whsec_ before computing the HMAC.
Verify against the raw request body bytes, exactly as received. If your framework parses JSON before you can read the raw body, the bytes change and verification will fail. Capture the raw body first (e.g. express.raw()), then parse only after the signature checks out.

Verification Code

Quidkey’s envelope and signature scheme are Stripe-compatible, so the Stripe SDK verifies them directly. Pass the raw body and the Stripe-Signature header, keyed by your full whsec_... secret.
import express from 'express';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_API_KEY);
const app = express();

// IMPORTANT: raw body so the bytes match what was signed
app.post(
  '/webhooks/quidkey',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,                       // raw Buffer
        req.get('stripe-signature'),    // t=...,v1=...
        process.env.QUIDKEY_WEBHOOK_SECRET, // full "whsec_..." secret
      );
    } catch (err) {
      return res.status(400).send(`Invalid signature: ${err.message}`);
    }

    // event is verified: safe to handle
    handleEvent(event);
    res.status(200).send('OK');
  },
);

Event Catalog

Event typeWhen it firesNotes
quidkey.payment_request.succeededThe transaction reached completed (or received)Includes the fees object. Fulfil the order here.
quidkey.payment_request.failedThe payment attempt failedNo fees.
quidkey.payment_request.canceledThe payment was cancelledOne l. See the warning below.
quidkey.payment_request.pendingPayment is in progressThe default in-flight state.
quidkey.payment_request.reversedA refund or reversal occurredAdjust your records accordingly.
The cancellation event is spelled quidkey.payment_request.canceled: one l, US spelling. Matching cancelled (two ls) will silently miss the event.
The failed and reversed events carry the same envelope, without a fees object:
failed
{
  "id": "evt_2b6d4e90-8c31-4a57-bf09-1d2e3f4a5b6c",
  "object": "event",
  "type": "quidkey.payment_request.failed",
  "data": {
    "object": {
      "id": "4a7b1e2c-9d83-4f10-a6b5-2e9c7d041f8a",
      "amount": "1999",
      "currency": "GBP",
      "status": "failed",
      "test": false,
      "metadata": { "order_id": "ORD-123" }
    }
  }
}
reversed
{
  "id": "evt_7e1a9c52-4f80-4b63-a2d1-6c9b8e0f3a47",
  "object": "event",
  "type": "quidkey.payment_request.reversed",
  "data": {
    "object": {
      "id": "4a7b1e2c-9d83-4f10-a6b5-2e9c7d041f8a",
      "amount": "1999",
      "currency": "GBP",
      "status": "completed",
      "test": false,
      "metadata": { "order_id": "ORD-123" }
    }
  }
}
Terminal vs transitional states. pending is transitional and resolves to one of succeeded, failed, or canceled. succeeded is otherwise terminal, but a settled payment can still move to reversed later if it is refunded or reversed. A reversal is identified by the event type (quidkey.payment_request.reversed); there is no distinct reversed status value, so data.object.status stays the underlying transaction status (e.g. completed).

Handling Events

function handleEvent(event) {
  // De-duplicate: skip if this event.id was already processed
  if (alreadyProcessed(event.id)) return;

  const payment = event.data.object;

  switch (event.type) {
    case 'quidkey.payment_request.succeeded':
      fulfilOrder(payment.metadata.order_id, payment);
      if (payment.fees) recordFees(payment.metadata.order_id, payment.fees);
      break;
    case 'quidkey.payment_request.failed':
      markFailed(payment.metadata.order_id);
      break;
    case 'quidkey.payment_request.canceled': // one 'l'
      markCancelled(payment.metadata.order_id);
      break;
    case 'quidkey.payment_request.pending':
      markPending(payment.metadata.order_id);
      break;
    case 'quidkey.payment_request.reversed':
      reverseOrder(payment.metadata.order_id);
      break;
  }

  markProcessed(event.id);
}

Delivery and De-duplication

Quidkey makes a single delivery attempt per event. There is no automatic retry. If your endpoint is down or returns a non-2xx, the event is not retried automatically; you resend it manually.
Manual resend. You can resend a delivery from the Quidkey Console. A resend reuses the same event.id and is re-signed with a fresh timestamp (so the signature and timestamp tolerance still validate).
Because resends reuse the same event.id, your handler must de-duplicate on event.id. Record processed event IDs and skip any you have already handled; otherwise a resend will fulfil the same order twice.
PropertyBehaviour
Delivery attemptsSingle attempt; no automatic retry
ResendManual, from the Console
Resend event.idSame as the original → de-duplicate on it
Resend signatureRe-signed with a fresh timestamp

Reconciling Missed Events

If you suspect a webhook was missed (endpoint downtime, etc.), reconcile by querying the payment’s current status directly. This protected merchant endpoint returns the authoritative state:
curl 'https://core.quidkey.com/api/v1/payment-requests/PAYMENT_REQUEST_ID/status' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN'
Use the status endpoint as a backstop, not a substitute for webhooks. Poll it for payments where you expected an event but never received one, then resend from the Console if needed.

Best Practices

Capture the raw body, verify the signature against it (full secret, including whsec_), and only then parse the JSON. Reject anything that fails with a 400.
Persist every processed event.id. Resends reuse the ID, so this is what keeps fulfilment exactly-once.
Return 200 quickly and do heavy work (fulfilment, emails) asynchronously. A slow handler can time out the delivery.
Reject events whose X-Timestamp is outside ~300 seconds of now to defend against replay attacks.

Next Steps

Testing

Trigger and verify test webhooks

Errors

Status codes and the error envelope

Amounts & Currencies

How amounts appear in payloads

Accept a Payment (Embedded)

Build the payment flow that triggers these events