Webhook endpoints
A webhook endpoint is a URL Ripllo will POST events to when something interesting happens in your workspace — a discount code is redeemed, a referral attribution is created, an abandoned cart is recovered. This page documents the API for managing those endpoints programmatically: registering new ones, inspecting recent deliveries, rotating secrets.
For the event payload format and per-event catalog, see Webhooks.
Outbound delivery is not shipped yet. The endpoint registry below is live — you can create, update and delete endpoints today, and the rows persist. What does not exist yet is the dispatcher: nothing reads those rows, nothing POSTs to your URL, nothing signs a request with the stored secret, and no
WebhookEventrow is ever written.GET /api/v1/webhooks/eventsconsequently returns an empty list on every workspace.Register endpoints now if it helps your provisioning scripts, but do not build a flow that waits on a delivery. Poll the resource endpoints instead until this page says otherwise.
You don't need this resource to register an endpoint. Most integrators add theirs once on Dashboard → Webhooks and never touch the API. The endpoints below exist for partners provisioning customer workspaces and infra-as-code setups.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/webhooks/endpoints |
List endpoints |
POST |
/api/v1/webhooks/endpoints |
Create an endpoint |
PATCH |
/api/v1/webhooks/endpoints/:id |
Update an endpoint |
DELETE |
/api/v1/webhooks/endpoints/:id |
Delete an endpoint |
GET |
/api/v1/webhooks/events |
List recent events |
All endpoints require an authenticated workspace principal — a portal session or any non-revoked HMAC key. There is no separate admin requirement: an API key needs read to list, and write to create, update or delete. Every principal that can reach the workspace can therefore register and delete delivery targets for it.
The webhook endpoint object
{
"id": "cl9x2k7t40000qz8f...",
"accountId": "acc_01HX...",
"url": "https://api.example.com/ripllo/webhooks",
"events": ["discount_code.redeemed", "abandoned_cart.recovered"],
"description": "Production handler",
"active": true,
"secretPreview": "whsec_…cd34",
"createdAt": "2026-05-12T10:42:00.000Z",
"updatedAt": "2026-05-13T08:11:00.000Z"
}
| Field | Type | Notes |
|---|---|---|
id |
string | Bare cuid — no type prefix. |
accountId |
string | Owning workspace. |
url |
string | The HTTPS URL events will POST to. https:// is required in every environment. |
events |
string[] | Event types this endpoint subscribes to. ["*"] means "everything". |
description |
string | null | Free-form label for your own bookkeeping. |
active |
boolean | Whether deliveries are attempted. |
secret |
string | The HMAC secret. Only present on create. Listing endpoints returns secretPreview (last 4 chars) instead. |
secretPreview |
string | whsec_…<last 4>. Safe to log. |
createdAt, updatedAt |
ISO 8601 |
The
secretis shown only on create. Every other endpoint returns it as omitted. Store it in your secret manager immediately — there's no recovery flow. To rotate, delete the endpoint and create a new one.
List endpoints
GET /api/v1/webhooks/endpoints
Returns every endpoint in the workspace, newest first. Secrets are stripped from the response (replaced with secretPreview).
{
"data": {
"endpoints": [
{
"id": "cl9x2k7t40000qz8f...",
"url": "https://api.example.com/ripllo/webhooks",
"events": ["*"],
"description": "Production handler",
"active": true,
"secretPreview": "whsec_…cd34",
"createdAt": "2026-05-12T10:42:00.000Z",
"updatedAt": "2026-05-12T10:42:00.000Z"
}
]
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Create an endpoint
POST /api/v1/webhooks/endpoints
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
url |
string (URL) | yes | Must start with https:// — there is no test-mode exception. Loopback, private-range, link-local and .local/.internal hosts are rejected (400 VALIDATION), so localhost and cloud metadata addresses can't be registered. For local development, expose your handler over an HTTPS tunnel (ngrok, cloudflared) and register the public URL. |
events |
string[] | no | Event types to subscribe to. Defaults to ["*"] for all events. Any string is accepted; nothing validates it against a catalog. |
description |
string | no | Free-form label. No length limit is enforced. |
Response — 201 Created
The full endpoint object with secret, which appears nowhere else:
{
"data": {
"endpoint": {
"id": "cl9x2k7t40000qz8f...",
"url": "https://api.example.com/ripllo/webhooks",
"events": ["discount_code.redeemed"],
"description": "Redemption tracker",
"active": true,
"createdAt": "2026-05-13T10:42:00.000Z"
},
"secret": "whsec_<32 bytes base64url>"
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
const { endpoint, secret } = await ripllo.webhooks.createEndpoint({
url: 'https://api.example.com/ripllo/webhooks',
events: ['discount_code.redeemed', 'abandoned_cart.recovered'],
});
await secretStore.set('RIPLLO_WEBHOOK_SECRET', secret);
Update an endpoint
PATCH /api/v1/webhooks/endpoints/:id
Partial. You can change url, events, description, and active. You cannot rotate the secret in-place — delete and recreate.
| Field | Mutable? | Notes |
|---|---|---|
url |
yes | |
events |
yes | Replaces the subscription list entirely. |
description |
yes | |
active |
yes | Set to false to pause deliveries without deleting. |
secret |
no | Delete and recreate. |
await ripllo.webhooks.updateEndpoint('cl9x2k7t40000qz8f...', {
active: false,
});
Delete an endpoint
DELETE /api/v1/webhooks/endpoints/:id
Hard delete. Audit log retains the deletion.
await ripllo.webhooks.deleteEndpoint('cl9x2k7t40000qz8f...');
List recent events
GET /api/v1/webhooks/events
Returns the most recent 50 delivery rows in the workspace, newest first.
Until the dispatcher ships this list is always empty — nothing writes WebhookEvent rows today. The shape below is the stored row as it will be returned once delivery exists:
{
"data": {
"events": [
{
"id": "cl9x2k7t40001qz8f...",
"accountId": "acc_01HX...",
"endpointId": "cl9x2k7t40000qz8f...",
"type": "discount_code.redeemed",
"payload": { /* the event envelope */ },
"status": "sent",
"attempts": 1,
"lastAttemptAt": "2026-05-13T10:43:22.000Z",
"nextRetryAt": null,
"responseCode": 200,
"responseBody": null,
"createdAt": "2026-05-13T10:43:21.000Z",
"updatedAt": "2026-05-13T10:43:22.000Z"
}
]
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
status is one of pending, sent, failed. attempts is the delivery counter (there is no attemptCount field).
Signature verification
Once delivery ships, every webhook Ripllo POSTs will carry a single Ripllo-Signature header holding both the timestamp and the digest:
Ripllo-Signature: t=<unix seconds>,v1=<hex>
Verify by parsing t and v1 out of that one header, then signing `${t}.${rawBody}` with the endpoint secret. There is no separate Ripllo-Signature-Timestamp header.
import crypto from 'node:crypto';
function verifyWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((s) => s.split('=').map((x) => x.trim())),
);
const { t, v1 } = parts;
if (!t || !v1) throw new Error('malformed signature header');
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) throw new Error('replay');
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
The Node SDK ships this as verifyWebhook({ rawBody, signature, secret, toleranceSec }) — it does the parse, the 5-minute replay window and the constant-time compare, and returns the parsed envelope. Use it rather than the sample above.
Events
The webhook endpoints resource itself is intentionally not broadcast on the event stream — subscribing to "webhook endpoint changed" via a webhook is a circular dependency we choose not to support. Changes are visible in the audit log.
Next
- Webhooks reference — envelope, retries, full event catalog.
- API keys — the other half of integration plumbing.