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

# Webhooks

> Receive checkout lifecycle events

You can manage your webhook endpoints from the **[Webhook section](https://dashboard.zenobank.io/developer)** in the dashboard.

## Setting up endpoints

1. Go to the **[Webhook section](https://dashboard.zenobank.io/developer)** in your store's dashboard.
2. Add an endpoint URL (e.g. `https://example.com/webhooks/zenobank`).
3. Select the event types you want to receive.

## Events

| Event                     | Description                                                                             |
| ------------------------- | --------------------------------------------------------------------------------------- |
| `checkout.completed`      | The full amount has been paid successfully.                                             |
| `checkout.expired`        | The checkout expired without receiving any payment.                                     |
| `checkout.partially_paid` | The checkout expired after receiving a partial payment (`paidAmount` \< `priceAmount`). |

## Payload format

<Tabs>
  <Tab title="checkout.completed">
    The customer paid the full amount. `paidAmount` is equal to or greater than `priceAmount`.

    ```json theme={null}
    {
      "type": "checkout.completed",
      "data": {
        "id": "ch_l0k1o87yt6",
        "orderId": "order-12345",
        "priceCurrency": "USD",
        "priceAmount": "100.00",
        "paidAmount": "100.00",
        "status": "COMPLETED",
        "expiresAt": "2025-10-05T12:00:00Z",
        "checkoutUrl": "https://pay.zenobank.io/ch_l0k1o87yt6",
        "createdAt": "2025-10-04T10:00:00Z",
        "successRedirectUrl": "https://example.com/success"
      }
    }
    ```
  </Tab>

  <Tab title="checkout.expired">
    No payment was received before the checkout expired. `paidAmount` is `"0"`.

    ```json theme={null}
    {
      "type": "checkout.expired",
      "data": {
        "id": "ch_9xR3mPq2vW",
        "orderId": "order-67890",
        "priceCurrency": "USD",
        "priceAmount": "50.00",
        "paidAmount": "0",
        "status": "EXPIRED",
        "expiresAt": "2025-10-05T12:00:00Z",
        "checkoutUrl": "https://pay.zenobank.io/ch_9xR3mPq2vW",
        "createdAt": "2025-10-04T10:00:00Z",
        "successRedirectUrl": null
      }
    }
    ```
  </Tab>

  <Tab title="checkout.partially_paid">
    The checkout expired after receiving a partial payment. `paidAmount` is greater than `"0"` but less than `priceAmount`. The collected funds will be credited to your store balance.

    ```json theme={null}
    {
      "type": "checkout.partially_paid",
      "data": {
        "id": "ch_4kT7nWx8pQ",
        "orderId": "order-11223",
        "priceCurrency": "USD",
        "priceAmount": "100.00",
        "paidAmount": "45.50",
        "status": "PARTIALLY_PAID",
        "expiresAt": "2025-10-05T12:00:00Z",
        "checkoutUrl": "https://pay.zenobank.io/ch_4kT7nWx8pQ",
        "createdAt": "2025-10-04T10:00:00Z",
        "successRedirectUrl": null
      }
    }
    ```
  </Tab>
</Tabs>

## Verifying webhooks

Every webhook request includes three headers used for signature verification:

| Header           | Description                   |
| ---------------- | ----------------------------- |
| `svix-id`        | Unique message identifier     |
| `svix-timestamp` | Unix timestamp of the message |
| `svix-signature` | HMAC signature                |

Use the official <a href="https://docs.svix.com/receiving/verifying-payloads/how" rel="nofollow">Svix library</a> to verify signatures. To find your signing secret, go to **[Developers](https://dashboard.zenobank.io/developer)**, click on a webhook endpoint, and copy the **Signing Secret** on the right side. It starts with `whsec_`.

### Install the Svix library

<Tabs>
  <Tab title="JavaScript">`bash pnpm add svix `</Tab>
  <Tab title="Python">`bash pip install svix `</Tab>
  <Tab title="PHP">`bash composer require svix/svix `</Tab>
  <Tab title="Go">`bash go get github.com/svix/svix-webhooks/go `</Tab>
  <Tab title="Ruby">`bash gem install svix `</Tab>
</Tabs>

### Verify the signature

<Warning>
  You must use the **raw request body** when verifying webhooks. The
  cryptographic signature is sensitive to even the slightest changes. Watch out
  for frameworks that parse the request as JSON and then stringify it, as this
  will break the signature verification.
</Warning>

<Tabs>
  <Tab title="JavaScript">
    ```javascript theme={null}
    import { Webhook } from 'svix';

    const secret = process.env.ZENO_WEBHOOK_SECRET; // whsec_...

    const payload = rawBody; // raw request body as string
    const headers = {
      'svix-id': request.headers.get('svix-id'),
      'svix-timestamp': request.headers.get('svix-timestamp'),
      'svix-signature': request.headers.get('svix-signature'),
    };

    const wh = new Webhook(secret);

    try {
      const msg = wh.verify(payload, headers);
      const { type, data } = msg;

      switch (type) {
        case 'checkout.completed':
          // Payment successful — fulfill the order
          console.log(`Order ${data.orderId} completed. Paid: ${data.paidAmount} ${data.priceCurrency}`);
          break;
        case 'checkout.expired':
          // Checkout expired — no payment was received
          console.log(`Order ${data.orderId} expired`);
          break;
        case 'checkout.partially_paid':
          // Partial payment received — consider issuing a refund
          console.log(`Order ${data.orderId} partially paid: ${data.paidAmount}/${data.priceAmount} ${data.priceCurrency}`);
          break;
      }
    } catch (err) {
      // Signature verification failed
      console.error('Invalid webhook signature');
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    from svix.webhooks import Webhook, WebhookVerificationError
    import os

    secret = os.environ.get('ZENO_WEBHOOK_SECRET')  # whsec_...

    payload = raw_body  # raw request body, not parsed JSON
    headers = request_headers

    wh = Webhook(secret)

    try:
        msg = wh.verify(payload, headers)
    except WebhookVerificationError:
        # Signature verification failed
        raise Exception('Invalid webhook signature')

    event_type = msg['type']

    if event_type == 'checkout.completed':
        # Payment successful — fulfill the order
        print(f"Order {msg['data']['orderId']} completed. Paid: {msg['data']['paidAmount']} {msg['data']['priceCurrency']}")
    elif event_type == 'checkout.expired':
        # Checkout expired — no payment was received
        print(f"Order {msg['data']['orderId']} expired")
    elif event_type == 'checkout.partially_paid':
        # Partial payment received — consider issuing a refund
        print(f"Order {msg['data']['orderId']} partially paid: {msg['data']['paidAmount']}/{msg['data']['priceAmount']} {msg['data']['priceCurrency']}")
    ```
  </Tab>

  <Tab title="PHP">
    ```php theme={null}
    use Svix\Webhook;
    use Svix\Exception\WebhookVerificationException;

    $secret = getenv('ZENO_WEBHOOK_SECRET'); // whsec_...

    $payload = file_get_contents('php://input'); // raw body
    $headers = array_change_key_case(getallheaders(), CASE_LOWER);

    try {
        $wh = new Webhook($secret);
        $msg = $wh->verify($payload, $headers);

        switch ($msg['type']) {
            case 'checkout.completed':
                // Payment successful — fulfill the order
                fulfillOrder($msg['data']['orderId']);
                break;
            case 'checkout.expired':
                // Checkout expired — no payment was received
                handleExpiredCheckout($msg['data']['orderId']);
                break;
            case 'checkout.partially_paid':
                // Partial payment received — consider issuing a refund
                handlePartialPayment($msg['data']['orderId'], $msg['data']['paidAmount']);
                break;
        }

        http_response_code(200);
    } catch (WebhookVerificationException $e) {
        // Signature verification failed
        http_response_code(400);
    }
    ```
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    import (
        "encoding/json"
        "io"
        "net/http"
        "os"

        svix "github.com/svix/svix-webhooks/go"
    )

    secret := os.Getenv("ZENO_WEBHOOK_SECRET") // whsec_...

    payload, _ := io.ReadAll(r.Body) // raw body

    wh, _ := svix.NewWebhook(secret)

    err := wh.Verify(payload, r.Header)
    if err != nil {
        // Signature verification failed
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    var event struct {
        Type string          `json:"type"`
        Data json.RawMessage `json:"data"`
    }
    json.Unmarshal(payload, &event)

    switch event.Type {
    case "checkout.completed":
        // Payment successful — fulfill the order
    case "checkout.expired":
        // Checkout expired — no payment was received
    case "checkout.partially_paid":
        // Partial payment received — consider issuing a refund
    }

    w.WriteHeader(http.StatusOK)
    ```
  </Tab>

  <Tab title="Ruby">
    ```ruby theme={null}
    require 'svix'
    require 'json'

    secret = ENV['ZENO_WEBHOOK_SECRET'] # whsec_...

    payload = raw_body # raw request body
    headers = request_headers

    wh = Svix::Webhook.new(secret)

    begin
      msg = wh.verify(payload, headers)

      case msg['type']
      when 'checkout.completed'
        # Payment successful — fulfill the order
        puts "Order #{msg['data']['orderId']} completed. Paid: #{msg['data']['paidAmount']} #{msg['data']['priceCurrency']}"
      when 'checkout.expired'
        # Checkout expired — no payment was received
        puts "Order #{msg['data']['orderId']} expired"
      when 'checkout.partially_paid'
        # Partial payment received — consider issuing a refund
        puts "Order #{msg['data']['orderId']} partially paid: #{msg['data']['paidAmount']}/#{msg['data']['priceAmount']} #{msg['data']['priceCurrency']}"
      end
    rescue
      # Signature verification failed
      puts 'Invalid webhook signature'
    end
    ```
  </Tab>
</Tabs>

<Warning>
  Always verify the signature before processing the event. Never trust the
  payload without verification. You must use the **raw request body**, do not
  parse the JSON before verifying.
</Warning>

For framework-specific examples (Express, Next.js, Django, Flask, Laravel, Rails, etc.), see the <a href="https://docs.svix.com/receiving/verifying-payloads/how" rel="nofollow">Svix verification docs</a>.

## FAQ

<AccordionGroup>
  <Accordion title="What is the retry schedule?">
    If Zeno Bank does not receive a `2xx` response from your webhook endpoint, we will retry the delivery.

    Each message is attempted based on the following schedule, where each period starts after the failure of the preceding attempt:

    * 5 seconds
    * 5 minutes
    * 30 minutes
    * 2 hours
    * 5 hours
    * 10 hours

    Respond with any `2xx` status code to acknowledge the event. You can monitor delivery attempts and manually retry from the **[Developers](https://dashboard.zenobank.io/developer)** section in the dashboard.
  </Accordion>

  <Accordion title="What are the delivery guarantees?">
    Zeno Bank webhooks provide **at-least-once** delivery. Every event will be delivered to your endpoint at least once, but may be delivered more than once in rare cases (such as network timeouts where your server processed the event but the acknowledgement was lost).

    To handle duplicates, use the `svix-id` header included with every webhook request. This is a unique identifier for each event delivery. Store processed `svix-id` values and skip any duplicates.
  </Accordion>

  <Accordion title="Can I retry webhook events manually?">
    Yes. Go to **[Developers](https://dashboard.zenobank.io/developer)** in the
    dashboard, click on your webhook endpoint, find the event you want to retry,
    and click the replay button to resend it.
  </Accordion>

  <Accordion title="Do I need to set up webhooks if I use a plugin?">
    No. If you use one of our official plugins (WooCommerce, PrestaShop, WHMCS, etc.), webhooks are configured automatically. You don't need to set up endpoints or verify signatures manually.
  </Accordion>
</AccordionGroup>
