Currency

Webhook endpoints

A webhook endpoint is a HTTPS URL you register with Ripllo to receive event notifications: discount-code redemptions, referral fulfillments, abandoned-cart recoveries, marketing-campaign send completions, and so on. Ripllo POSTs a signed JSON envelope to your URL; your handler verifies the signature and reacts. This page covers ripllo.webhooks on the Node SDK. For the HTTP surface and the full event catalog, see API → Webhook endpoints; for signature verification, see Webhooks.

Namespace

ripllo.webhooks.listEndpoints()
ripllo.webhooks.createEndpoint(input)
ripllo.webhooks.updateEndpoint(id, patch)
ripllo.webhooks.deleteEndpoint(id)

ripllo.webhooks.listEvents(params?)

Five methods: CRUD on the subscription registry plus a read-only event-history feed (useful for "where's my event?" debugging in the dashboard).

Methods — endpoints

createEndpoint

Signature. ripllo.webhooks.createEndpoint(input): Promise<{ endpoint: WebhookEndpoint; secret: string }>

Registers a new endpoint. The response carries the secret Ripllo will sign deliveries with as a sibling of endpoint, not as a field on it — endpoint.secret is deliberately undefined. This is the only call that returns the secret in plaintext.

const { endpoint, secret } = await ripllo.webhooks.createEndpoint({
  url: 'https://yourapp.com/webhooks/ripllo',
  events: ['discount_code.redeemed', 'referral.fulfilled'],
  description: 'Reward fulfillment listener',
});

console.log(endpoint.id);     // cuid, e.g. 'clx8f2k9r0000...'
console.log(secret);          // 'whsec_...' — STORE NOW

url must be https:// and must not resolve to a loopback, private, link-local or .local/.internal host — anything else is a 400 VALIDATION.

If you omit events, the endpoint is stored subscribed to the wildcard ['*'], i.e. all events. An explicitly empty array is rejected: send at least one entry, or leave the key out. Most teams scope down to the specific event types they care about.

listEndpoints

Signature. ripllo.webhooks.listEndpoints(): Promise<{ endpoints: WebhookEndpoint[] }>

Returns every endpoint in the workspace, newest first. The secret field is never returned on list — you get a secretPreview (whsec_…cd34) instead, enough to tell two keys apart. If you've lost the secret, rotate the endpoint (delete + recreate) rather than trying to recover.

const { endpoints } = await ripllo.webhooks.listEndpoints();
for (const e of endpoints) {
  const scope = e.events.includes('*') ? 'all' : `${e.events.length}`;
  console.log(`${e.id} → ${e.url} (${scope} events)`);
}

updateEndpoint

await ripllo.webhooks.updateEndpoint('we_01HX...', {
  events: ['discount_code.redeemed', 'referral.fulfilled', 'abandoned_cart.recovered'],
});

PATCH; pass only the fields you want to change. The secret is not rotateable through update — delete and re-create if you need to rotate.

deleteEndpoint

await ripllo.webhooks.deleteEndpoint('we_01HX...');

Hard delete. In-flight deliveries already queued may still arrive; queued retries are dropped.

Methods — events

listEvents

Signature. ripllo.webhooks.listEvents(): Promise<{ events: WebhookEvent[] }>

Returns the workspace's 50 most recent events, newest first. The SDK method accepts type / limit / cursor params, but the server currently ignores all three — there is no filtering and no pagination, and no nextCursor comes back. Filter client-side for now.

const { events } = await ripllo.webhooks.listEvents();
for (const e of events) console.log(e.id, e.createdAt, e.status);

Not yet delivering. Registering endpoints, storing their secrets and reading this feed all work, but no worker dispatches to WebhookEndpoint rows yet — nothing writes WebhookEvent rows either, so the feed is empty on every workspace. Register your endpoints now if you like; treat inbound deliveries as not yet available. There is no replay endpoint.

Types

interface WebhookEndpoint {
  id: string;                  // cuid
  accountId: string;
  url: string;                 // https only
  description: string | null;
  events: string[];            // ['*'] → subscribed to all (the default)
  active: boolean;
  secretPreview: string | null; // on list; 'whsec_…cd34'
  createdAt: string;
  updatedAt: string;
}
// createEndpoint resolves to { endpoint: WebhookEndpoint; secret: string }
// — the plaintext secret is NOT a field on the endpoint.

interface WebhookEvent {
  id: string;                  // cuid
  accountId: string;
  endpointId: string;
  type: string;                // e.g. 'discount_code.redeemed'
  payload: Record<string, unknown>;
  status: 'pending' | 'sent' | 'failed';
  attempts: number;
  lastAttemptAt: string | null;
  nextRetryAt: string | null;
  responseCode: number | null;
  createdAt: string;
  updatedAt: string;
}

Common patterns

Register on first boot

async function ensureWebhook(url: string) {
  const { endpoints } = await ripllo.webhooks.listEndpoints();
  const existing = endpoints.find((e) => e.url === url);
  if (existing) return existing;
  const { endpoint, secret } = await ripllo.webhooks.createEndpoint({
    url,
    events: ['discount_code.redeemed', 'referral.fulfilled', 'abandoned_cart.recovered'],
  });
  await secretManager.put('RIPLLO_WEBHOOK_SECRET', secret);
  return endpoint;
}

Verify inbound deliveries

import { verifyWebhook } from '@forjio/ripllo-node';

app.post('/webhooks/ripllo', express.raw({ type: 'application/json' }), (req, res) => {
  const event = verifyWebhook({
    rawBody: req.body,
    signature: req.headers['ripllo-signature'] as string,
    secret: process.env.RIPLLO_WEBHOOK_SECRET!,
  });
  // ...dispatch on event.type...
  res.status(200).end();
});

verifyWebhook throws a plain Error on tamper / replay / wrong secret — not a RiplloError, so don't branch on e.code. The messages are missing Ripllo-Signature header, malformed signature header, non-numeric timestamp, signature timestamp <n>s out of tolerance (default tolerance 300s), bad signature, and webhook body is not valid JSON. Catch broadly and return a 400. See Webhooks for the full handler shape (raw-body parsing, idempotency, retry semantics).

Rotate the secret

async function rotateWebhookSecret(oldId: string, url: string, events: string[]) {
  const { secret } = await ripllo.webhooks.createEndpoint({ url, events });
  await secretManager.put('RIPLLO_WEBHOOK_SECRET_NEW', secret);
  // After both old and new are accepted by your handler:
  await ripllo.webhooks.deleteEndpoint(oldId);
  await secretManager.put('RIPLLO_WEBHOOK_SECRET', secret);
}

During rotation, dual-verify in your handler: accept either the old or the new secret. Once the old endpoint is deleted, drop the old secret from your config.

Errors

Code Status Cause
VALIDATION 400 URL not HTTPS, URL points at a private/loopback/link-local host, or events is an empty array.
NOT_FOUND 404 Endpoint ID doesn't exist in this workspace.
NO_ACCOUNT 403 The principal carries no accountId.
INSUFFICIENT_SCOPE 403 API key lacks read (for listEndpoints/listEvents) or write (for create/update/delete).

See Errors for handling.

Next

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