---
url: /webhooks.md
---
# Webhooks

Maytes notifies your backend of checkout state changes you can't observe synchronously. In the [webhook flow](/how-it-works#the-webhook-flow), `checkout.authorized` triggers capture, so your webhook endpoint is a required part of the integration.

Deliveries are signed **HMAC-SHA256** POSTs to your configured HTTPS URL. Manage the subscription, signing secret, and delivery log in the **merchant portal** or through the [subscription API](#managing-your-subscription) using your merchant API OAuth credentials.

***

## Event types

| Event                 | When it fires                                                                                                                                                                    | What to do                                                                                                           |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `checkout.authorized` | The customer completed payment in the Maytes window; funds are held.                                                                                                             | **Capture or cancel immediately.** The customer waits on a processing screen while you decide.                       |
| `checkout.voided`     | The authorization was released. `data.reason` is `merchant_cancel` (you canceled) or `auth_expired` (the hold lapsed before capture).                                            | Release stock and mark the order unpaid.                                                                             |
| `marketing.opted_in`  | A customer ticked your marketing opt-in box on a Maytes screen (once marketing opt-in is enabled for your store). Sent for **every** tick, including a repeat on the same order. | Add or update the contact in your marketing tool. See [Handling `marketing.opted_in`](#handling-marketing-opted-in). |
| `webhook.test`        | Sent only when you trigger a test event from the portal; real checkouts never fire it.                                                                                           | Verify your endpoint end-to-end before going live.                                                                   |

There is **no `checkout.captured` webhook**. Capture is a synchronous call you make, and its 200 response confirms success. There is also no refund webhook: after you [issue a refund](/refunds), observe the outcome through `GET /checkouts/{uuid}`.

When subscribing, list the event types you want, or use `"*"` to receive everything.

## The envelope

Every delivery has the same shape:

```json
{
  "id": "evt_0192aa10-...",
  "type": "checkout.authorized",
  "api_version": "2026-06",
  "created_at": "2026-09-14T03:30:12.000Z",
  "data": {
    "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931",
    "merchant_order_id": "order_1001",
    "status": "authorized",
    "amount": 4500,
    "currency": "AUD",
    "user_uuid": "0192a7c1-..."
  }
}
```

| Field         | Meaning                                                                                                                                     |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`          | Stable event id, **unchanged across every retry**. Use it to deduplicate.                                                                   |
| `type`        | The event type (see table above).                                                                                                           |
| `api_version` | Envelope schema version. Additive changes (new optional fields) do **not** bump it; breaking changes do. Ignore fields you don't recognize. |
| `created_at`  | When the event occurred.                                                                                                                    |
| `data`        | Per-event payload.                                                                                                                          |

Payloads are intentionally minimal. `checkout.authorized` includes what you need to capture directly: `checkout_uuid`, `merchant_order_id`, `amount`, and `currency`. For the full state, call [`GET /checkouts/{uuid}`](/capture-and-status#reading-the-checkout-status). `checkout.voided` includes `checkout_uuid`, `merchant_order_id`, `status`, and `reason`. `marketing.opted_in` has its own payload, [described below](#handling-marketing-opted-in).

***

## Verify the signature

Every delivery is signed. **Verify it with the [backend SDK](/build-your-integration) before trusting the request.** One call verifies the signature and returns the parsed event:

::: code-group

```ts [TypeScript]
import express from 'express';
import { verifyWebhookSignature, WebhookSignatureError } from '@maytes/api-client-js';

// express.raw gives you the exact bytes we signed. Do NOT use express.json() here.
app.post('/webhooks/maytes', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const event = verifyWebhookSignature(
      req.body, // raw bytes
      req.header('x-maytes-signature'),
      process.env.MAYTES_WEBHOOK_SECRET,
    );

    // Verified. Safe to act on — see the capture recipe below.
    await handleMaytesEvent(event);
    res.status(200).end();
  } catch (err) {
    if (err instanceof WebhookSignatureError) {
      console.warn('rejected maytes webhook', err.reason);
      return res.status(400).end();
    }
    throw err;
  }
});
```

```python [Python]
import os
from flask import Flask, request
from maytes_api_client import verify_webhook_signature, WebhookSignatureError

@app.post("/webhooks/maytes")
def maytes_webhook():
    try:
        event = verify_webhook_signature(
            request.get_data(),                        # raw bytes
            request.headers.get("X-Maytes-Signature"),
            os.environ["MAYTES_WEBHOOK_SECRET"],
        )
    except WebhookSignatureError as err:
        app.logger.warning("rejected maytes webhook: %s", err.reason)
        return "", 400

    # Verified. Safe to act on — see the capture recipe below.
    handle_maytes_event(event)
    return "", 200
```

```php [PHP]
<?php

use MaytesApiClient\WebhookVerifier;
use MaytesApiClient\WebhookSignatureError;

try {
    $event = WebhookVerifier::verify(
        file_get_contents('php://input'),              // raw body
        $_SERVER['HTTP_X_MAYTES_SIGNATURE'] ?? null,
        getenv('MAYTES_WEBHOOK_SECRET'),
    );
} catch (WebhookSignatureError $e) {
    error_log('rejected maytes webhook: ' . $e->getReason());
    http_response_code(400);
    return;
}

// Verified. Safe to act on — see the capture recipe below.
handleMaytesEvent($event);
http_response_code(200);
```

```go [Go]
import maytes "github.com/maytes/api-client-go"

func maytesWebhook(w http.ResponseWriter, r *http.Request) {
    rawBody, err := io.ReadAll(r.Body)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    event, err := maytes.VerifyWebhookSignature(
        rawBody,
        r.Header.Get("X-Maytes-Signature"),
        os.Getenv("MAYTES_WEBHOOK_SECRET"),
    )
    if err != nil {
        log.Printf("rejected maytes webhook: %v", err)
        w.WriteHeader(http.StatusBadRequest)
        return
    }

    // Verified. Safe to act on — see the capture recipe below.
    handleMaytesEvent(event)
    w.WriteHeader(http.StatusOK)
}
```

```java [Java]
import co.maytes.api.webhook.WebhookVerifier;

byte[] rawBody = request.getInputStream().readAllBytes();
try {
    var event = WebhookVerifier.verify(
        rawBody,
        request.getHeader("X-Maytes-Signature"),
        System.getenv("MAYTES_WEBHOOK_SECRET"));

    // Verified. Safe to act on — see the capture recipe below.
    handleMaytesEvent(event);
    response.setStatus(200);
} catch (WebhookVerifier.WebhookSignatureException e) {
    logger.warn("rejected maytes webhook: {}", e.getReason());
    response.setStatus(400);
}
```

```ruby [Ruby]
require 'maytes_api_client'

post '/webhooks/maytes' do
  begin
    event = MaytesApiClient.verify_webhook_signature(
      request.body.read,                          # raw body
      request.env['HTTP_X_MAYTES_SIGNATURE'],
      ENV.fetch('MAYTES_WEBHOOK_SECRET'),
    )
  rescue MaytesApiClient::WebhookSignatureError => e
    logger.warn("rejected maytes webhook: #{e.reason}")
    halt 400
  end

  # Verified. Safe to act on — see the capture recipe below.
  handle_maytes_event(event)
  status 200
end
```

```csharp [.NET]
using MaytesApiClient.Webhooks;

app.MapPost("/webhooks/maytes", async (HttpRequest request) =>
{
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);

    try
    {
        var evt = WebhookVerifier.Verify(
            buffer.ToArray(),                       // raw bytes
            request.Headers["X-Maytes-Signature"],
            Environment.GetEnvironmentVariable("MAYTES_WEBHOOK_SECRET"));

        // Verified. Safe to act on — see the capture recipe below.
        await HandleMaytesEventAsync(evt);
        return Results.Ok();
    }
    catch (WebhookSignatureException e)
    {
        logger.LogWarning("rejected maytes webhook: {Reason}", e.Reason);
        return Results.BadRequest();
    }
});
```

:::

The SDK handles four security-sensitive checks:

| The SDK handles                | What goes wrong without it                                                                                                                     |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Hashes the **raw bytes**       | Re-serializing JSON (`JSON.stringify(req.body)`) can change key order and number formatting, causing verification to fail.                     |
| Accepts **any** matching `v1=` | During a [secret rotation](#rotating-your-signing-secret), the header carries two signatures. Reading only the first can reject valid traffic. |
| **Constant-time** comparison   | A normal `==` on digests leaks how much of a forged signature was right, one byte at a time.                                                   |
| Rejects **stale** deliveries   | Without a freshness check on `t`, a captured delivery stays replayable forever.                                                                |

Follow three rules:

1. **Give it the raw body.** Use your framework's raw-body option (`express.raw`, `request.get_data()`, `php://input`) and keep JSON middleware off this route. The call returns the parsed event, so you never parse it yourself.
2. **Respond `400` and stop** when it throws. Don't log, queue, or retry the body. Treat the request as unverified.
3. **Keep the signing secret server-side**, in your secret manager alongside your OAuth credentials. It's shown **once**, when the subscription is created or rotated.

Log the error's `reason` to distinguish `malformed_header`, `timestamp_out_of_tolerance` (clock skew or replay), `no_matching_signature` (wrong secret or modified body), and `invalid_json`.

::: details What the header looks like
Deliveries include the signature in `X-Maytes-Signature` and repeat the timestamp in `X-Maytes-Timestamp`:

```
X-Maytes-Signature: t=1757820612,v1=5257a869e7...,v1=7a3f0e11bc...
X-Maytes-Timestamp: 1757820612
```

In the [delivery log](#the-delivery-log) or a request dump, two `v1` entries indicate a [secret rotation](#rotating-your-signing-secret) within its grace window.

Let the SDK verify the header. We don't publish the signing algorithm because we can't update a deployed custom verifier when security issues arise, and verification failures can be silent. If we don't support your language, [tell us](/portal); we'll prioritize support instead of asking you to write a verifier.
:::

## Handling `checkout.authorized`

1. **Verify the signature** ([above](#verify-the-signature)). If it fails, respond `400` and stop.
2. **Deduplicate on `event.id`.** Delivery is [at-least-once](#delivery-semantics), and the id is stable across retries. This protects your side effects; the capture call is also [idempotent](/capture-and-status#idempotency-retries).
3. **Act on it.** Capture the payment, or cancel if you can't fulfill the order.
4. **Respond 2xx**, within the [15-second timeout](#timing-expectations).

**[Capture & checkout status](/capture-and-status)** covers capture and cancel calls, capture failures, and the [complete handler in all seven languages](/capture-and-status#putting-it-together-your-webhook-handler).

The customer waits on a processing screen during this flow. Keep pre-capture checks to fast lookups, and defer slow fulfillment work until after you respond. See [Timing expectations](#timing-expectations).

## Handling `marketing.opted_in`

Once marketing opt-in is enabled for your store, Maytes shows your opt-in wording to the customer who placed the order (the lead) and to friends paying their share. Each time someone ticks your box, you receive `marketing.opted_in`. This is the only webhook that carries **personal data**, so store and handle it under your privacy policy.

```json
{
  "id": "evt_0192ab77-...",
  "type": "marketing.opted_in",
  "api_version": "2026-06",
  "created_at": "2026-09-24T03:00:02.000Z",
  "data": {
    "customer_id": "6f1c2a3b-5c1e-5b9a-9d2f-3e4a5b6c7d8e",
    "subject": "lead",
    "order": {
      "merchant_order_id": "order_1001",
      "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931",
      "payment_request_uuid": null
    },
    "customer": {
      "name": "Ada Lovelace",
      "email": "ada@example.com",
      "phone": "+61400000001"
    },
    "opt_in": {
      "email_marketing": true,
      "sms_marketing": true,
      "opted_in_at": "2026-09-24T03:00:00.000Z",
      "placement": "success_page",
      "text_version": 3
    }
  }
}
```

| Field                                            | Meaning                                                                                                                                                                                                              |
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `customer_id`                                    | A stable id for this person **at your store**. Use it to match repeat opt-ins. The same person has a different `customer_id` at another merchant.                                                                    |
| `subject`                                        | `lead` (the customer who placed the order) or `friend` (someone paying a share of it).                                                                                                                               |
| `order`                                          | The order the opt-in was given on. `payment_request_uuid` is set for a `friend`. It tells you **where** the opt-in happened, not whether the order was paid. Use `checkout.authorized` / `checkout.voided` for that. |
| `customer.email`                                 | The address the customer entered **with this opt-in**. A later opt-in from the same `customer_id` may carry a different address. The newest one is current.                                                          |
| `customer.name`, `customer.phone`                | From the customer's Maytes account at send time. `phone` is E.164 and is only included when the customer has verified it. Either may be `null`.                                                                      |
| `opt_in.email_marketing`, `opt_in.sms_marketing` | `true` for each channel we have a contact for, otherwise `null`. One tick covers both.                                                                                                                               |
| `opt_in.opted_in_at`                             | When the customer ticked the box.                                                                                                                                                                                    |
| `opt_in.placement`                               | Where the box was shown: `success_page`, `pay_link`, `app`, `email_link` or `other`.                                                                                                                                 |
| `opt_in.text_version`                            | The version of your opt-in wording the customer saw. Keep it with the contact as evidence of what they agreed to.                                                                                                    |

To handle it:

1. **Verify the signature** and **deduplicate on `event.id`**, as for every event.
2. **Upsert the contact** keyed on `customer_id`, using `customer.email` (and `customer.phone` for SMS if you use it).
3. **Respond 2xx.** There is no deadline beyond the 15-second timeout. If you fail, we keep retrying for about 3 days (see [Delivery semantics](#delivery-semantics)).

After this, the relationship is yours. Unsubscribes happen in your own emails and tools, and Maytes doesn't need to be told. Every tick is sent, even from a customer who later unsubscribed from you. Treat a new `marketing.opted_in` as a fresh opt-in.

## Timing expectations

The authorization is short-lived, and the customer waits on a payment-processing screen:

| Stage                             | Expectation                                                                                                                                                                                                   |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Delivery of `checkout.authorized` | Sent within seconds of the customer authorizing.                                                                                                                                                              |
| Your endpoint's response          | Respond **2xx within 15 seconds** — the enforced per-attempt timeout. A verification + inline capture fits comfortably.                                                                                       |
| **Your capture (or cancel)**      | **Immediately, in the handler.** If it isn't captured, the authorization is **automatically voided after 2 minutes**. This window absorbs delivery retries and transient errors; it is not for deferred work. |
| If the window closes              | You receive [`checkout.voided`](#event-types) with `reason: "auth_expired"`. The customer's payment doesn't complete, the order remains unpaid, and later capture attempts return `409 not_capturable`.       |

Capture **inline in the handler**. Keep pre-capture stock and fraud checks fast, and defer emails and warehouse calls until after you respond. Run longer checks *before* creating the checkout.

## Delivery semantics

* **At-least-once.** The same event can be delivered more than once; `id` is stable across retries. Deduplicate on it.
* **No ordering guarantee.** Handle each event independently; don't assume `authorized` arrives before customer support sees the order.
* **2xx means delivered.** Anything else is a failure, recorded in your delivery log:
  * **`checkout.authorized`** (time-boxed by the 2-minute authorization window):
    * **408 / 429 / 5xx / network errors / timeouts** → retried up to 3 times within the window, then parked for investigation.
    * **Other 4xx** → *not* retried. Your endpoint rejected a well-formed delivery, possibly because of a wrong path, broken verifier, or unexpected content-type assertion. Fix the endpoint and check the [delivery log](#the-delivery-log).
  * **`checkout.voided` and `marketing.opted_in`** aren't time-sensitive, so **any** non-2xx (including 4xx) is retried with increasing gaps for about **3 days** (9 attempts), with the same `id` each time.
* **15-second timeout** per attempt. Respond promptly; do slow work after responding.
* **Missed events**: your safety net is reconciliation — for any order stuck in an unexpected state, `GET /checkouts/{uuid}` gives you the current truth.

## Managing your subscription

Manage the same subscription **in the merchant portal**, **over the API**, or with both.

**In the portal** (access comes with [onboarding](/onboarding#_2-get-access-to-the-merchant-portal)), you can:

* **Set your endpoint URL and event types.**
* **Collect the signing secret** at creation, and **rotate** it later.
* **Send a `webhook.test` event** to check your endpoint before real traffic depends on it.
* **Read the [delivery log](#the-delivery-log)** for every attempt and returned HTTP status.

Sending test events is portal-only. The delivery log is available in both the portal and API.

**Over the API**, use your merchant OAuth credentials:

| Operation                                                | Endpoint                                       |
| -------------------------------------------------------- | ---------------------------------------------- |
| Register or update your subscription (URL + event types) | `POST /api/merchant/v1/webhooks/subscription`  |
| Rotate the signing secret                                | `POST /api/merchant/v1/webhooks/secret/rotate` |
| List recent delivery attempts                            | `GET /api/merchant/v1/webhooks/deliveries`     |

```bash
curl -s https://api.maytes.co/api/merchant/v1/webhooks/subscription \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H 'content-type: application/json' \
  -d '{
    "url": "https://shop.example.com/webhooks/maytes",
    "event_types": ["checkout.authorized", "checkout.voided"]
  }'
```

Store the response's `signing_secret` immediately. It appears **only when the subscription is first created** and is not shown again. Updating the URL or event types doesn't change it.

### Rotating your signing secret

To rotate without downtime:

1. Rotate in the portal or through the API endpoint above. The response carries the **new** secret.
2. For the next **2 hours**, every delivery is signed with **both** secrets — two `v1` entries in the header. Your verifier keeps matching on the old secret.
3. Deploy the new secret within the grace window. The old secret then stops being used automatically.

For a suspected leak, pass `revoke_previous=true` to skip the grace window and revoke the old secret immediately. Deliveries may fail until you deploy the new secret.

### The delivery log

`GET /api/merchant/v1/webhooks/deliveries` (also available in the portal) returns every attempt with `event_id`, `event_type`, `attempt_number`, `http_status`, `error_message`, and `duration_ms`. Paginate with `limit` (default 50, max 200) and `cursor`.

## Testing your endpoint

1. In the **sandbox environment**, subscribe your endpoint. For local development, expose it through a tunnel such as ngrok or cloudflared; public HTTPS deliveries can't reach `localhost` directly.
2. Send a **`webhook.test`** event from the portal. It bypasses your event-type filter and always arrives. Confirm signature verification passes and you respond 2xx.
3. Run a real test-card checkout end-to-end and confirm your handler captures. The [sandbox environment](/sandbox-environment) page has the card numbers and a full pre-go-live checklist.

## Endpoint requirements

* **HTTPS**, publicly reachable.
* Respond **2xx** within 15 seconds once you've verified and recorded the event.
* Verify the signature on **every** request. The endpoint URL is guessable; the signature proves the delivery came from Maytes.

## What's next

* [Quickstart](/quickstart) — wire the webhook flow end to end.
* [Capture & checkout status](/capture-and-status) — everything about capture, cancel, and the status lifecycle.
* [Security](/security) — credential and secret handling across the whole integration.
