Skip to content

Webhooks

Maytes notifies your backend when a checkout changes state you can't observe synchronously. In the webhook flow, the checkout.authorized event is what triggers your capture — so treat your webhook endpoint as a first-class part of your integration, not an afterthought.

Deliveries are signed HMAC-SHA256 POSTs to the HTTPS URL you configure. You manage your subscription, signing secret, and delivery log in the merchant portal, or via the subscription API with the same OAuth credentials as the rest of the merchant API.


Event types

EventWhen it firesWhat to do
checkout.authorizedThe customer completed payment in the Maytes window; funds are held.Capture (or cancel) — your customer is watching a processing screen while you decide.
checkout.voidedThe authorization was released. data.reason tells you why: merchant_cancel (you cancelled) or auth_expired (the hold lapsed before capture).Release stock, mark the order unpaid.
webhook.testOnly when you send a test event from the portal — never fired by real checkouts.Verify your endpoint end-to-end before going live.

There is no checkout.captured webhook, deliberately: capture is a synchronous call you make — the 200 response is your confirmation, so a webhook would be redundant. There's no refund webhook either: refunds are issued from the merchant portal, and your integration simply observes the outcome on 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": "0192a8f2-...",
    "merchant_order_id": "order_1001",
    "status": "authorized",
    "amount": 4500,
    "currency": "AUD",
    "user_uuid": "0192a7c1-..."
  }
}
FieldMeaning
idStable event id — the same across every retry of this event. Use it to deduplicate.
typeThe event type (see table above).
api_versionEnvelope schema version. Additive changes (new optional fields) do not bump it; breaking changes do. Ignore fields you don't recognise.
created_atWhen the event occurred.
dataPer-event payload.

Payloads are intentionally minimal — checkout.authorized carries enough to capture directly (checkout_uuid, merchant_order_id, amount, currency); call GET /checkouts/{uuid} if you need the full checkout state. checkout.voided carries checkout_uuid, merchant_order_id, status, and reason.


Verify the signature

Every delivery carries an X-Maytes-Signature header:

X-Maytes-Signature: t=1757820612,v1=5257a869e7...,v1=7a3f0e11bc...
  • t — unix timestamp (seconds) of the delivery attempt.
  • v1 — lowercase hex of HMAC_SHA256(signing_secret, "{t}.{raw_body}").
  • There can be two v1 entries during a secret rotation grace window — one per secret. Accept the delivery if any v1 matches. A verifier that only reads the first entry will break during every rotation.

To verify:

  1. Read the raw request body — the exact bytes, before any JSON parsing. Re-serialising parsed JSON produces different bytes and a false mismatch; this is the single most common webhook integration bug.
  2. Compute HMAC_SHA256(secret, "{t}.{raw_body}") as lowercase hex.
  3. Compare against each v1 value using a constant-time comparison.
  4. Reject if t is too old — we recommend a 5-minute tolerance to blunt replay of captured requests.
ts
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verifyMaytesSignature(rawBody: string, header: string, secret: string): boolean {
  const parts = header.split(',');
  const t = parts.find((p) => p.startsWith('t='))?.slice(2);
  const signatures = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
  if (!t || signatures.length === 0) return false;

  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return signatures.some((sig) => {
    const a = Buffer.from(sig, 'hex');
    const b = Buffer.from(expected, 'hex');
    return a.length === b.length && timingSafeEqual(a, b);
  });
}

// Express: capture the raw body — do NOT verify against JSON.stringify(req.body).
app.post('/webhooks/maytes', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifyMaytesSignature(
    req.body.toString('utf8'),
    req.header('x-maytes-signature') ?? '',
    process.env.MAYTES_WEBHOOK_SECRET!,
  );
  if (!ok) return res.status(400).send('invalid signature');
  const event = JSON.parse(req.body.toString('utf8'));
  // ... handle event (see recipe below), then:
  res.status(200).end();
});
python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300

def verify_maytes_signature(raw_body: bytes, header: str, secret: str) -> bool:
    parts = header.split(",")
    t = next((p[2:] for p in parts if p.startswith("t=")), None)
    signatures = [p[3:] for p in parts if p.startswith("v1=")]
    if not t or not signatures:
        return False

    if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
        return False

    signed_payload = t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, sig) for sig in signatures)

# Flask: use request.get_data() (raw bytes), not request.json re-serialised.
php
<?php

const TOLERANCE_SECONDS = 300;

function verifyMaytesSignature(string $rawBody, string $header, string $secret): bool
{
    $t = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        if (str_starts_with($part, 't=')) {
            $t = substr($part, 2);
        } elseif (str_starts_with($part, 'v1=')) {
            $signatures[] = substr($part, 3);
        }
    }
    if ($t === null || $signatures === []) {
        return false;
    }

    if (abs(time() - (int) $t) > TOLERANCE_SECONDS) {
        return false;
    }

    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($signatures as $sig) {
        if (hash_equals($expected, $sig)) {
            return true;
        }
    }
    return false;
}

// Use file_get_contents('php://input') as the raw body — not a re-encoded array.

Your signing secret is shown once, when the subscription is created (or rotated). Store it in your secret manager alongside your OAuth credentials — never in browser code or your repo.


Handling checkout.authorized: the capture recipe

Your customer is on a payment-processing screen in the Maytes window while this handler runs, so the shape that serves them best is: verify, capture inline, respond.

ts
async function handleCheckoutAuthorized(event: MaytesEvent) {
  const { checkout_uuid, merchant_order_id, amount, currency } = event.data;

  // 1. Dedupe — deliveries are at-least-once.
  if (await alreadyProcessed(event.id)) return;

  // 2. Your final checks (stock, fraud, order still open).
  const order = await orders.findByRef(merchant_order_id);
  if (!order || !order.isFulfillable()) {
    // Can't fulfil → release the hold so the customer isn't left waiting.
    await maytes.cancelCheckout(checkout_uuid);
    return;
  }

  // 3. Capture — synchronous; 200 means the money moved.
  //    The assertions guard against acting on the wrong order.
  await maytes.captureCheckout(checkout_uuid, {
    expected_total_amount: amount,
    currency,
    merchant_order_id,
  });

  // 4. Fulfil. This is your trigger — not the return_url landing.
  await orders.markPaidAndFulfil(order, checkout_uuid);
  await markProcessed(event.id);
}

Notes on the edges:

  • Capture errors: a 409 not_capturable usually means the checkout was already voided (e.g. the authorization expired before your retry landed) — treat the order as unpaid. If the capture call itself fails transiently, retry once, then cancel rather than leaving the customer on the processing screen.
  • Already captured: capture is idempotent on terminal states — a duplicate delivery that re-runs capture returns success without double-charging. Deduping on event.id avoids re-running your own fulfilment side effects.
  • Keep it fast: deliveries time out after 30 seconds, and the authorization is auto-voided 2 minutes in if nothing captures it — see Timing expectations. A verification + synchronous capture fits comfortably; queue heavy fulfilment work (emails, warehouse calls) for after you respond.

Timing expectations

The checkout.authorized → capture loop runs while your customer watches a payment-processing screen, and the authorization itself is short-lived:

StageExpectation
Delivery of checkout.authorizedSent within seconds of the customer authorizing.
Your endpoint's responseRespond 2xx within 30 seconds — the enforced per-attempt timeout. A verification + inline capture fits comfortably.
Your capture (or cancel)Immediately, in the handler. The authorization is automatically voided 2 minutes in if nothing has captured it — the window exists to absorb delivery retries and transient errors, not for deferred work.
If the window closesThe void fires, you receive checkout.voided with reason: "auth_expired", the customer's payment doesn't complete, and the order is unpaid. A capture attempt after the void returns 409 not_capturable.

Practically: capture inline in the handler, keep pre-capture checks (stock, fraud) to fast lookups, and defer slow fulfilment work (emails, warehouse calls) until after you've responded. If your checks need longer than the window allows, do them before creating the checkout, not after the customer has paid.

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 on its own merits; don't assume authorized arrives before your customer support sees the order.
  • 2xx means delivered. Anything else is a failure, recorded in your delivery log:
    • 408 / 429 / 5xx / network errors / timeouts → retried up to 3 times, then parked for investigation.
    • Other 4xxnot retried. A 4xx means your endpoint rejected a well-formed delivery — wrong path, broken verifier, unexpected content-type assertion. Fix the endpoint and check the delivery log.
  • 30-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

The merchant portal is the easiest way to configure webhooks. The same operations exist as API endpoints under your merchant OAuth credentials:

OperationEndpoint
Register or update your subscription (URL + event types)POST /api/merchant/v1/webhooks/subscription
Rotate the signing secretPOST /api/merchant/v1/webhooks/secret/rotate
List recent delivery attemptsGET /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"]
  }'

The response includes signing_secret only when the subscription is first created — store it immediately; it is not shown again. Updating the URL or event types later leaves the secret unchanged.

Rotating your signing secret

Rotation is designed for zero-downtime:

  1. Rotate (portal, or POST /webhooks/secret/rotate). 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 to your config within the grace window. Done — the old secret stops being used automatically.

Suspected leak? Pass revoke_previous=true on the rotate call to skip the grace window and kill the old secret immediately (deliveries may fail until your new secret is deployed — that's the point).

The delivery log

GET /api/merchant/v1/webhooks/deliveries (also in the portal) returns every attempt with event_id, event_type, attempt_number, http_status, error_message, and duration_ms — enough to answer "did you send it, what did my endpoint say" without a support ticket. Paginate with limit (default 50, max 200) and cursor.

Testing your endpoint

  1. In the sandbox environment, subscribe your endpoint (a tunnel such as ngrok or cloudflared works for local development — deliveries are HTTPS POSTs from the public internet, so localhost isn't reachable directly).
  2. Send a webhook.test event from the portal. It bypasses your event-type filter, so it always lands — verify your signature check passes and you respond 2xx.
  3. Run a real test-card checkout end-to-end and confirm your handler captures. The sandbox environment page has the card numbers and a full pre-go-live checklist.

Endpoint requirements

  • HTTPS, publicly reachable.
  • Respond 2xx within 30 seconds once you've verified and recorded the event.
  • Verify the signature on every request — your endpoint URL is guessable; the signature is what proves a delivery came from Maytes.

What's next

  • Quickstart — wire the webhook flow end to end.
  • Capture & order status — everything about capture, cancel, and the status lifecycle.
  • Security — credential and secret handling across the whole integration.