Vendu Docs
ProductDevelopers
/

Product

Getting started

  • What Vendu does
  • How the agent decides what to say

Team

  • Roles and permissions

Orders

  • The order lifecycle

Automation

  • Triggers and actions

Apps

  • What apps are

Conversations

  • When the agent hands over

Developers

API

  • Build on Vendu
  • API error codes
  • Make your first API call
  • MCP server
  • Migrate from offset to cursor pagination
  • Outbound webhooks
  • Rate limits
  • Versioning and deprecation

Apps

  • Build an app

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.refunded
    • message.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 2xx status 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

TypeWhen
order.createdA new order has been created (locally or from an external CRM).
order.paidOrder payment confirmed (MonoPay or via CRM).
order.shippedTTN issued; order moved to SHIPPED.
order.cancelledOrder moved to CANCELLED.
order.refundedRefundOrderSaga completed.
message.receivedA 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.

Last updated 2026-07-29

On this page

  • Configure
  • Payload shape
  • Verify the signature
  • Node.js
  • Python
  • Retry policy
  • Delivery log
  • Idempotency
  • Sample event types