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

# Receiving Webhooks

> Configure your webhook URL, enable off-by-default events, and build an endpoint that verifies signatures and responds correctly.

This page walks through configuring Soap to send you webhooks and building an endpoint that handles them correctly. For the event catalog and payload shapes, see [Webhooks Overview](/api-reference/api-v1/webhooks/webhooks).

<Steps>
  <Step title="Set your webhook URL and copy your signing secret">
    In your [Soap Dashboard](https://dashboard-sandbox.paywithsoap.com), go to **Developers** and set the URL where Soap should send webhook events. Then copy your webhook signing secret (format `wss_...`) — you'll need it to verify signatures.

    <Warning>
      Treat your signing secret like a password. Store it in an environment variable, never commit it to source control, and never send it to the client.
    </Warning>
  </Step>

  <Step title="Enable the events you need">
    4 events are always sent and require no action: `checkout.succeeded`, `checkout.hold`, `checkout.release_hold`, and `checkout.returned`.

    The other 8 events are **off by default** and must be explicitly enabled in Dashboard → Developers before Soap will ever deliver them:

    * `checkout.pending`
    * `checkout.failed`
    * `checkout.voided`
    * `checkout.expired`
    * `checkout.terminally_failed`
    * `checkout.review.created`
    * `checkout.review.approved`
    * `checkout.review.declined`

    If you write a handler for one of these events and it never seems to fire, check whether it's enabled here first.
  </Step>

  <Step title="Expose localhost for local development">
    Soap's servers can't reach `localhost` directly. Use a tunneling tool such as [ngrok](https://ngrok.com) to expose your local dev server:

    ```bash theme={null}
    ngrok http 3000
    ```

    Point your dashboard webhook URL at the resulting `https://*.ngrok.io` (or `ngrok-free.app`) URL plus your webhook path, e.g. `https://abcd1234.ngrok.io/webhooks/soap`. Update it again if the tunnel URL changes.
  </Step>

  <Step title="Build an endpoint that reads the raw body, verifies, and responds quickly">
    Your endpoint must:

    1. Read the **raw request body** — not a body that's already been parsed into an object and would need to be re-serialized. See the raw body warning in the [Webhooks Overview](/api-reference/api-v1/webhooks/webhooks#verifying-webhook-signatures).
    2. Verify the `SOAP-WEBHOOK-SIGNATURE` header against that raw body.
    3. Return a `2xx` status **quickly** — before doing slow work like calling other APIs or waiting on a database transaction.
    4. Do any slow processing **asynchronously**, after responding, e.g. by enqueueing a background job.

    Delivery attempts time out after 10 seconds. If your handler is slow, Soap sees it as a failure and retries — which can cause duplicate processing if you're not using `event_id` as an idempotency key.
  </Step>

  <Step title="Handle checkout.hold specially">
    `checkout.hold` is the one event where your response status changes what Soap does next:

    * Respond **`2xx`** to indicate the customer **has** sufficient available funds — the withdrawal proceeds.
    * Respond **non-`2xx`** to indicate the customer has **insufficient** funds — the withdrawal does not proceed.

    Unlike every other event, `checkout.hold` is delivered synchronously and is not retried, so this check needs to complete within the 10 second timeout. See [`checkout.hold`](/api-reference/api-v1/webhooks/checkout-hold) for details.
  </Step>
</Steps>

## Minimal Express Handler

```javascript theme={null}
const express = require('express');
const crypto = require('crypto');

const app = express();

function verifySignature(payload, signatureHeader, signingSecret) {
  const parts = signatureHeader.split(',');
  const timestampPart = parts.find((p) => p.startsWith('t='));
  const signaturePart = parts.find((p) => p.startsWith('v1='));

  if (!timestampPart || !signaturePart) {
    return false;
  }

  const timestamp = timestampPart.split('=')[1];
  const receivedSignature = signaturePart.split('=')[1];

  // timingSafeEqual throws if the two buffers differ in length, so reject
  // anything that is not a full SHA-256 hex digest before comparing.
  if (!/^[0-9a-f]{64}$/i.test(receivedSignature)) {
    return false;
  }

  const message = `${timestamp}.${payload}`;

  const calculatedSignature = crypto
    .createHmac('sha256', signingSecret)
    .update(message)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(receivedSignature, 'hex'),
    Buffer.from(calculatedSignature, 'hex')
  );
}

// express.raw() keeps req.body as an untouched Buffer of raw bytes,
// which is required for signature verification to succeed.
app.post('/webhooks/soap', express.raw({ type: 'application/json' }), (req, res) => {
  const signatureHeader = req.headers['soap-webhook-signature'];
  const payload = req.body.toString('utf8');

  if (!signatureHeader || !verifySignature(payload, signatureHeader, process.env.SOAP_WEBHOOK_SECRET)) {
    return res.status(422).send('Invalid signature');
  }

  const event = JSON.parse(payload);

  switch (event.type) {
    case 'checkout.hold': {
      // Respond 2xx if the customer has sufficient funds, non-2xx otherwise.
      // This must complete quickly — checkout.hold is synchronous and not retried.
      const hasSufficientFunds = true; // your balance check here
      return res.sendStatus(hasSufficientFunds ? 200 : 402);
    }

    case 'checkout.succeeded':
      // Enqueue async processing, then respond quickly.
      enqueueLedgerUpdate(event);
      return res.sendStatus(200);

    default:
      // Acknowledge events you don't act on yet so Soap doesn't retry them.
      return res.sendStatus(200);
  }
});

function enqueueLedgerUpdate(event) {
  // Use event.event_id as your idempotency key before applying the update.
}
```

## Testing Your Handler

There is currently no "send test webhook" button in the dashboard. To exercise your handler end-to-end, complete a real checkout against the sandbox:

1. Point your webhook URL at your local tunnel (see above).
2. Create a sandbox checkout and complete it with the test card `4111-1111-1111-1111` and CVV `999` (see [Testing](/api-reference/api-v1/testing) for other sandbox test methods).
3. Watch your endpoint receive the resulting `checkout.succeeded` (and, for withdrawals, `checkout.hold`) delivery.

To see the other 8 events, remember to enable them first (Step 2 above) and drive the corresponding scenario — e.g. a declined test card for `checkout.failed`.
