Skip to content

Webhooks ​

Maytes notifies your backend of checkout state changes you can't observe synchronously. In 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 using your merchant API OAuth credentials.


Event types ​

EventWhen it firesWhat to do
checkout.authorizedThe 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.voidedThe 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_inA 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.
webhook.testSent 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, 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-..."
  }
}
FieldMeaning
idStable event id, unchanged across every retry. 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 recognize.
created_atWhen the event occurred.
dataPer-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}. checkout.voided includes checkout_uuid, merchant_order_id, status, and reason. marketing.opted_in has its own payload, described below.


Verify the signature ​

Every delivery is signed. Verify it with the backend SDK before trusting the request. One call verifies the signature and returns the parsed event:

ts
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
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

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
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
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
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
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 handlesWhat goes wrong without it
Hashes the raw bytesRe-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, the header carries two signatures. Reading only the first can reject valid traffic.
Constant-time comparisonA normal == on digests leaks how much of a forged signature was right, one byte at a time.
Rejects stale deliveriesWithout 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.

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 or a request dump, two v1 entries indicate a secret rotation 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; we'll prioritize support instead of asking you to write a verifier.

Handling checkout.authorized ​

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

Capture & checkout status covers capture and cancel calls, capture failures, and the complete handler in all seven languages.

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.

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
    }
  }
}
FieldMeaning
customer_idA 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.
subjectlead (the customer who placed the order) or friend (someone paying a share of it).
orderThe 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.emailThe 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.phoneFrom 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_marketingtrue for each channel we have a contact for, otherwise null. One tick covers both.
opt_in.opted_in_atWhen the customer ticked the box.
opt_in.placementWhere the box was shown: success_page, pay_link, app, email_link or other.
opt_in.text_versionThe 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).

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:

StageExpectation
Delivery of checkout.authorizedSent within seconds of the customer authorizing.
Your endpoint's responseRespond 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 closesYou receive checkout.voided 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.
    • 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), 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 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:

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"]
  }'

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 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 ​