Currency

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 WebhookEvent row is ever written. GET /api/v1/webhooks/events consequently 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 secret is 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

CurrencyRupiah is paid by transfer or QRIS; US dollars settle through PayPal.