Reference

Webhooks

Merida POSTs signed JSON events to your endpoint so you can react to payments, subscriptions, and customer changes without polling.

Configure an endpoint

Add your URL in Settings → Webhooks. Each endpoint gets its own signing secret (prefix whsec_) shown exactly once. Store it in your secret manager alongside your API key.

Group endpoints (partners)

If you run many practices under one group, you do not need a webhook per practice. Register a single group-level endpoint with your group key and every practice's events are delivered to it, signed with that endpoint's secret:

bash
curl -X POST https://api.meridapay.com/partner/webhook-endpoints \
  -H "Authorization: Bearer $MERIDA_GROUP_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://yourapp.com/webhooks", "event_types": ["payment.refunded", "payment.refund_failed"] }'

The response returns the signing secret once. The endpoint's environment (test or live) matches the group key you use, and delivery, signing, and retries work exactly like a normal endpoint. Omit event_types to default to the refund lifecycle, or pass ["*"] for everything.

Event catalog

The four primary events (highlighted below) are what most integrations subscribe to. The rest fire on organisation and account lifecycle changes.

  • payment.succeeded
    A one-time checkout or subscription charge succeeded. Carries paymentLinkId, metadata, and externalCustomerId.
  • payment.failed
    Payment attempt failed. Integrators typically show a retry prompt.
  • payment.errored
    Payment processor raised an unexpected error. Similar to payment.failed but usually transient.
  • payment.refunded
    A refund on a payment completed and the funds are on their way back to the customer. Carries the paymentId, the refund amount, and the round-trip fields from the original checkout. Fires for both full and partial refunds, and once a pending refund finishes settling.
  • payment.refund_failed
    A refund that was accepted could not be completed. The payment stays in its prior settled state and the funds were not returned.
  • invoice.created
    A new invoice was generated. Fires at subscription renewal and ad-hoc billing runs.
  • invoice.updated
    An existing invoice was credited, adjusted, or refunded.
  • subscription.created
    A subscription was created (checkout success, manual create, or trial start).
  • subscription.updated
    A subscription's plan, price, or scheduled state changed.
  • subscription.canceled
    A subscription was canceled (immediately or at period end).
  • subscription.expired
    A subscription reached its end and is no longer active.
  • subscription.trial_ended
    A trial completed. The subscription has either converted or expired.
  • customer.created
    A new customer account was created in your org.
  • customer.updated
    A customer account's details (email, billing address) changed.
  • subscription.overdue_changed
    A customer's overdue state changed (e.g. moved into dunning).
  • subscription.blocked
    Entitlement blocking state changed (feature access gated by payment status).
  • tag.created
    A tag was applied to an account, subscription, or invoice.
  • tag.deleted
    A tag was removed.
  • custom_field.created
    A custom field was added to an object.
  • custom_field.deleted
    A custom field was removed.
  • organization.config_changed
    Org-level configuration was updated.
  • entitlement.granted
    A feature/credit allowance was granted to a customer (subscription start, plan change, or manual top-up).
  • entitlement.exhausted
    A customer hit zero remaining balance on a metered feature in the current period.
  • usage.recorded
    A /track or /events/ingest call recorded usage that affected a customer_meter balance.

Delivery headers

Every delivery carries two header families:

webhook-id:        <event uuid>
webhook-timestamp: <unix-seconds>
webhook-signature: v1,<base64 HMAC-SHA256>
X-Merida-Signature: t=<unix-seconds>,v1=<hex HMAC-SHA256>
X-Merida-Timestamp: <unix-seconds>
Content-Type: application/json

The first three are the Standard Webhooks spec, the recommended path. The signature is the HMAC-SHA256 of the literal string "{webhook-id}.{webhook-timestamp}.{raw_body}", base64-encoded, keyed by the portion of your secret after the whsec_ prefix.

The X-Merida-* pair is the original Merida format, kept for one release cycle so existing integrations keep working. New integrations should verify against webhook-signature only.

Sample payload

Every event shares a top-level envelope (id, type, timestamp, data). The data object's shape depends on the event type. Below is a payment.succeeded:

json
{
  "id": "<event-uuid>",
  "type": "payment.succeeded",
  "timestamp": "2026-04-22T12:34:56.000Z",
  "data": {
    "id": "<event-uuid>",
    "eventType": "payment.succeeded",
    "objectId": "<payment-uuid>",
    "objectType": "PAYMENT",
    "accountId": "<merida-account-uuid>",
    "organizationId": "<your-org-uuid>",
    "timestamp": "2026-04-22T12:34:56.000Z",
    "paymentId": "<payment-uuid>",
    "transactionId": "<transaction-uuid>",
    "subscriptionId": null,
    "amount": "29.00",
    "currency": "USD",
    "transactionType": "PURCHASE",
    "paymentLinkId": "<payment-link-uuid>",
    "paymentLinkToken": "abc123XYZ",
    "productId": "<product-uuid>",
    "customerEmail": "[email protected]",
    "externalCustomerId": "user_123",
    "clientReferenceId": "order_456",
    "metadata": { "plan": "pro", "org_id": "acme" }
  }
}

When the payment belongs to a subscription, subscriptionId is set and objectType is "SUBSCRIPTION":

  • Signup (transactionType: "SUBSCRIPTION_START"): paymentId and transactionId are null (no discrete payment row is recorded at signup), and subscriptionId carries the subscription. Read subscriptionId (or objectId), never paymentId, to identify the subscription.
  • Renewal (transactionType: "RECURRING"): paymentId is a real charge id and subscriptionId links it to the subscription.

Subscription event payloads

subscription.created, subscription.updated, and subscription.canceled share the same data shape. objectId and subscriptionId are both the subscription's UUID. status is "active", "paused", or "canceled".

json
{
  "id": "<event-uuid>",
  "type": "subscription.created",
  "timestamp": "2026-04-22T12:34:56.000Z",
  "data": {
    "id": "<event-uuid>",
    "eventType": "subscription.created",
    "objectId": "<subscription-uuid>",
    "objectType": "SUBSCRIPTION",
    "accountId": "<merida-account-uuid>",
    "organizationId": "<your-org-uuid>",
    "timestamp": "2026-04-22T12:34:56.000Z",
    "subscriptionId": "<subscription-uuid>",
    "bundleId": "<bundle-uuid>",
    "planName": "Pro",
    "phaseName": null,
    "priceListName": "DEFAULT",
    "productId": "<product-uuid>",
    "status": "active",
    "cancelAtPeriodEnd": false,
    "cancellationEffectiveAt": null,
    "currentPeriodEnd": "2026-05-22T12:34:56.000Z",
    "externalCustomerId": "user_123",
    "customerEmail": "[email protected]"
  }
}

When each fires:

  • subscription.created: at signup (checkout success or a manual create). The cleanest place to capture the subscription id against your own user.
  • subscription.updated: on pause, resume, plan change, or undo of a scheduled cancellation. Inspect status and cancelAtPeriodEnd.
  • subscription.canceled: when a cancellation is requested, for both immediate and period-end. For a period-end cancel the row stays active until cancellationEffectiveAt, so status is still "active" and cancelAtPeriodEnd is true. For an immediate cancel status is "canceled".
  • subscription.expired: when the subscription actually ends. This is the other half of a period-end cancel (it fires at cancellationEffectiveAt, after the earlier subscription.canceled), and also fires when a payment mandate is lost or dunning is exhausted. status is "canceled". This is the signal to revoke access.

Customer event payloads

customer.created and customer.updated fire when a customer is created (checkout, buy link, or API) or edited. objectId is the Merida customer UUID, and externalCustomerId is your own id.

json
{
  "id": "<event-uuid>",
  "type": "customer.created",
  "timestamp": "2026-04-22T12:34:56.000Z",
  "data": {
    "id": "<event-uuid>",
    "eventType": "customer.created",
    "objectId": "<merida-customer-uuid>",
    "objectType": "CUSTOMER",
    "accountId": "<merida-customer-uuid>",
    "organizationId": "<your-org-uuid>",
    "timestamp": "2026-04-22T12:34:56.000Z",
    "externalCustomerId": "user_123",
    "email": "[email protected]",
    "name": "Jane Doe"
  }
}

Verify with the SDK (Next.js)

The fastest path on App Router: one Webhooks(...) factory with named callbacks per event type. Signature verification, JSON parsing, and dispatch are handled for you.

typescript
// app/api/webhooks/merida/route.ts
import { Webhooks } from "@meridapay/sdk/webhooks"

export const POST = Webhooks({
  secret: process.env.MERIDA_WEBHOOK_SECRET!,
  onPaymentSucceeded:     async (event) => { /* grant access */ },
  onSubscriptionCanceled: async (event) => { /* revoke access */ },
  onPaymentFailed:        async (event) => { /* trigger dunning */ },
  onEvent:                async (event) => { /* fallback */ },
})

Every event type from the catalog above has its own optional on* callback (e.g. onCustomerCreated, onInvoiceUpdated, onEntitlementGranted). The factory returns a plain (Request) => Promise<Response>, so the same export works in Pages-API edge handlers, Hono, Bun.serve, Cloudflare Workers, and Deno.

Verify with the SDK (Astro)

Astro's APIRoute is ({ request }) => Response, so the same factory drops in with a one-line wrapper.

typescript
// src/pages/api/webhooks/merida.ts
import type { APIRoute } from "astro"
import { Webhooks } from "@meridapay/sdk/webhooks"

export const prerender = false

const handler = Webhooks({
  secret: import.meta.env.MERIDA_WEBHOOK_SECRET,
  onPaymentSucceeded:     async (event) => { /* grant access */ },
  onSubscriptionCanceled: async (event) => { /* revoke access */ },
  onPaymentFailed:        async (event) => { /* trigger dunning */ },
  onEvent:                async (event) => { /* fallback */ },
})

export const POST: APIRoute = ({ request }) => handler(request)

Deploy targets: Vercel (@astrojs/vercel), Cloudflare (@astrojs/cloudflare), Node (@astrojs/node). All three work because the factory never touches platform globals beyond Request / Response / Web Crypto.

Verify signatures (Node.js, Standard Webhooks)

typescript
import crypto from "node:crypto"

export default async function handler(req, res) {
  const id = req.headers["webhook-id"]
  const timestamp = req.headers["webhook-timestamp"]
  const sigHeader = req.headers["webhook-signature"]   // "v1,<base64> v1,<base64> ..."
  const rawBody = await readRawBody(req)                // string, before JSON.parse

  const secret = process.env.MERIDA_WEBHOOK_SECRET.replace(/^whsec_/, "")
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64")

  const candidates = sigHeader
    .split(/\s+/)
    .filter((p) => p.startsWith("v1,"))
    .map((p) => p.slice(3))

  const match = candidates.some(
    (c) => c.length === expected.length &&
           crypto.timingSafeEqual(Buffer.from(c), Buffer.from(expected))
  )
  if (!match) return res.status(401).end("bad signature")

  const event = JSON.parse(rawBody)
  res.status(200).end("ok")
}

Verify signatures (Python)

python
import hmac, hashlib, os, base64

def verify(request):
    id = request.headers["webhook-id"]
    timestamp = request.headers["webhook-timestamp"]
    signature = request.headers["webhook-signature"]   # "v1,<base64> v1,<base64> ..."
    raw = request.data                                  # bytes, before json.loads

    secret = os.environ["MERIDA_WEBHOOK_SECRET"]
    if secret.startswith("whsec_"):
        secret = secret[6:]

    expected = base64.b64encode(
        hmac.new(
            secret.encode(),
            f"{id}.{timestamp}.{raw.decode()}".encode(),
            hashlib.sha256,
        ).digest()
    ).decode()

    candidates = [
        p.split(",", 1)[1]
        for p in signature.split()
        if p.startswith("v1,")
    ]

    if not any(hmac.compare_digest(c, expected) for c in candidates):
        return ("bad signature", 401)

    # ok - dispatch on event["type"]
    return ("ok", 200)

Retries and ordering

Non-2xx responses trigger exponential backoff retries for up to 24 hours. Deliveries can arrive out of order, so use event.timestamp and event.id (idempotent on your side) to de-duplicate and sequence.

Correlating to your user

Every payment event includes the externalCustomerId and clientReferenceId you sent when creating the checkout, plus the full metadata map. Prefer those over customerEmail, since emails change.