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:
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.succeededA one-time checkout or subscription charge succeeded. Carries paymentLinkId, metadata, and externalCustomerId.
- payment.failedPayment attempt failed. Integrators typically show a retry prompt.
- payment.erroredPayment processor raised an unexpected error. Similar to payment.failed but usually transient.
- payment.refundedA 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_failedA refund that was accepted could not be completed. The payment stays in its prior settled state and the funds were not returned.
- invoice.createdA new invoice was generated. Fires at subscription renewal and ad-hoc billing runs.
- invoice.updatedAn existing invoice was credited, adjusted, or refunded.
- subscription.createdA subscription was created (checkout success, manual create, or trial start).
- subscription.updatedA subscription's plan, price, or scheduled state changed.
- subscription.canceledA subscription was canceled (immediately or at period end).
- subscription.expiredA subscription reached its end and is no longer active.
- subscription.trial_endedA trial completed. The subscription has either converted or expired.
- customer.createdA new customer account was created in your org.
- customer.updatedA customer account's details (email, billing address) changed.
- subscription.overdue_changedA customer's overdue state changed (e.g. moved into dunning).
- subscription.blockedEntitlement blocking state changed (feature access gated by payment status).
- tag.createdA tag was applied to an account, subscription, or invoice.
- tag.deletedA tag was removed.
- custom_field.createdA custom field was added to an object.
- custom_field.deletedA custom field was removed.
- organization.config_changedOrg-level configuration was updated.
- entitlement.grantedA feature/credit allowance was granted to a customer (subscription start, plan change, or manual top-up).
- entitlement.exhaustedA customer hit zero remaining balance on a metered feature in the current period.
- usage.recordedA /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:
{
"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"):paymentIdandtransactionIdarenull(no discrete payment row is recorded at signup), andsubscriptionIdcarries the subscription. ReadsubscriptionId(orobjectId), neverpaymentId, to identify the subscription. - Renewal (
transactionType: "RECURRING"):paymentIdis a real charge id andsubscriptionIdlinks 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".
{
"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. InspectstatusandcancelAtPeriodEnd.subscription.canceled: when a cancellation is requested, for both immediate and period-end. For a period-end cancel the row stays active untilcancellationEffectiveAt, sostatusis still"active"andcancelAtPeriodEndistrue. For an immediate cancelstatusis"canceled".subscription.expired: when the subscription actually ends. This is the other half of a period-end cancel (it fires atcancellationEffectiveAt, after the earliersubscription.canceled), and also fires when a payment mandate is lost or dunning is exhausted.statusis"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.
{
"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.
// 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.
// 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)
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)
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.