api · reference
Outbound webhooks
Event types Vendu delivers to your endpoint, the payload shape, and how delivery is retried.
Vendu POSTs your domain events (order.created, order.paid, …) to your URL. Payloads are HMAC-signed and retried on failure. Last updated: 2026-05-25
Configure
/dashboard/settings/webhooks → Add endpoint. Required:
- HTTPS URL —
http://is rejected. - Event — pick one of:
order.created,order.paid,order.shipped,order.cancelled,order.refundedmessage.received- (more land per release notes)
- We generate a 32-byte
vwh_…secret. Copy it once at creation; it's how you verify our signature.
Payload shape
POST /your/handler HTTP/1.1
Content-Type: application/json
X-Vendu-Signature: t=1748131200,v1=…hmac-hex…
X-Vendu-Event: order.created
X-Vendu-Delivery-Id: <webhookId>:<attempt>:<unix_ts>
User-Agent: Vendu-Webhooks/1.0
{
"id": "42",
"type": "order.created",
"created_at": "2026-05-25T12:00:00.000Z",
"data": { /* domain-event payload */ }
}
Verify the signature
The signature is Stripe-style: t=<unix_ts>,v1=<hex(hmac_sha256(secret, t + '.' + raw_body))>.
Use the raw request body — re-serialising the JSON breaks the
match.
Node.js
import crypto from 'node:crypto'
function verifyVenduWebhook(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(',').map(p => p.trim().split('=')))
const ts = parseInt(parts.t, 10)
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) {
return false // expired (>5 minutes)
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.v1, 'hex')
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.strip().split('=') for p in header.split(','))
ts = int(parts['t'])
if abs(time.time() - ts) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{ts}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts['v1'])
Retry policy
- Total budget: 5 attempts (1 + 4 retries).
- Schedule (controlled by QStash on our side): roughly 2s, 8s, 60s, 5m, 30m.
- A response with
2xxstatus counts as success. Anything else, plus network errors and timeouts (>10s), counts as failure. - After 5 consecutive failures the webhook is auto-disabled.
We email tenant admins and surface the disabled state at
/dashboard/settings/webhooks. Tenant re-enables manually after fixing the receiver.
Delivery log
/dashboard/settings/webhooks → click the history icon on a row to
see the most recent attempts: status code (or ERR for network
failures), truncated error text, attempt number, duration, age.
Idempotency
X-Vendu-Delivery-Id is unique per (webhook, attempt, signature_ts)
— record the values you've already processed and short-circuit
duplicates. Retries reuse the same event id in the body (id
field), so a Stripe-style "dedupe on event id" approach also works.
Sample event types
| Type | When |
|---|---|
order.created | A new order has been created (locally or from an external CRM). |
order.paid | Order payment confirmed (MonoPay or via CRM). |
order.shipped | TTN issued; order moved to SHIPPED. |
order.cancelled | Order moved to CANCELLED. |
order.refunded | RefundOrderSaga completed. |
message.received | A customer message hit one of your channels. |
For the canonical list, query event_type values from your outbox
table or check the domain events module.