Webhooks

Pluralize can call your backend when something happens to one of your tenants — an account is deleted, a verification email needs resending, and more event types over time. Use webhooks to keep your own systems in sync: send a branded "your account was removed" email, purge a cache, kick off an export, ping Slack.

Webhooks are server-to-server. Pluralize POSTs to a URL on your backend, signs the body with a secret only you and Pluralize share, and you verify that signature before trusting anything. Never point a webhook at a browser.

Configure an endpoint

In Dashboard → Settings → Webhooks (or PUT /api/v1/admin/apps/{appId}/event-webhook-config):

  1. Enter an HTTPS URL on your backend, e.g. https://api.yourapp.example/webhooks/pluralize.
  2. Pluralize returns a signing secret that starts with pls_evt_. It is shown once — copy it into your backend's environment (e.g. PLURALIZE_WEBHOOK_SECRET).
  3. Rotate the secret any time from the same page (or POST /api/v1/admin/apps/{appId}/event-webhook-config/rotate-secret). The old secret stops working immediately, so deploy the new one to your backend first, then rotate.

Delivery contract

  • One attempt, no retries. If your endpoint is down or returns a status of 400 or higher, the event is recorded as failed in your dashboard's delivery log and is not retried. Make your handler resilient — don't rely on Pluralize to redeliver.
  • 1.5 second timeout. Acknowledge fast. Return a 2xx as soon as you have verified the signature and enqueued the work; do the slow part (sending email, writing to a queue) after you respond, not before.
  • Out-of-band. Events are dispatched separately from the request that triggered them. Treat them as eventually-delivered, not synchronous with the originating action.
  • If no webhook URL is configured when an event fires, Pluralize still records an invalid_config delivery row, so you can see "this event happened but no endpoint was registered" in the log.

Request format

Every delivery is an HTTP POST with a JSON body:

{
  "event": "tenant.deleted",
  "timestamp": 1718200000,
  "appId": "a1b2c3d4-0000-0000-0000-000000000000",
  "tenantId": "e5f6a7b8-0000-0000-0000-000000000000",
  "email": "ada@example.com",
  "payload": { "reason": "self_delete", "locale": "en" }
}

| Field | Type | Notes | | --- | --- | --- | | event | string | Event name, e.g. tenant.deleted. | | timestamp | number | Unix seconds. Matches the x-pluralize-timestamp header; used for replay protection. | | appId | string | The app the event belongs to. | | tenantId | string or null | The affected tenant, when applicable. | | email | string or null | The tenant's email at the time of the event (captured before deletion). | | payload | object | Event-specific fields — see the catalog below. |

Headers on every request:

| Header | Example | Purpose | | --- | --- | --- | | x-pluralize-signature | t=1718200000,v1=9f8e7d... | HMAC of the body. Verify this. | | x-pluralize-timestamp | 1718200000 | Unix seconds; reject if too old. | | user-agent | Pluralize-Webhook/1.0 | Identifies the sender. | | content-type | application/json | The body is JSON. |

Event catalog

| Event | Fires when | payload | | --- | --- | --- | | tenant.deleted | A tenant account is permanently deleted — by the tenant (POST /api/v1/tenants/me/delete) or by you from the dashboard. | reason ("self_delete" or "admin_delete"), locale ("en", "es", or "nl") | | tenant.email.resend_verification | You trigger a verification-email resend from the dashboard and your app delivers its own email. | verifyToken (string), verifyUrl (string), expiresAt (ISO timestamp) |

New event types will be added over time. Treat an unknown event value as a no-op — ignore it rather than erroring — so future events never break your endpoint.

Verifying the signature

The signature proves the request came from Pluralize and the body wasn't tampered with. Always verify before trusting the body. The scheme:

  1. Read the raw request body as text — not parsed-then-reserialized JSON. Re-encoding changes the bytes and breaks the HMAC.
  2. Recompute HMAC_SHA256(secret, "{timestamp}.{rawBody}") as lowercase hex.
  3. Constant-time compare it against the v1= value in x-pluralize-signature.
  4. Reject if the timestamp is more than 5 minutes away from now (replay protection).
// app/webhooks/pluralize/route.ts  (Next.js App Router)
import { createHmac, timingSafeEqual } from 'node:crypto';
 
const SECRET = process.env.PLURALIZE_WEBHOOK_SECRET!; // pls_evt_...
const REPLAY_WINDOW_S = 5 * 60;
 
export async function POST(req: Request) {
  const raw = await req.text(); // RAW body — do NOT JSON.parse before verifying
  const sigHeader = req.headers.get('x-pluralize-signature') ?? '';
  const tsHeader = req.headers.get('x-pluralize-timestamp') ?? '';
 
  const ts = Number(tsHeader);
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > REPLAY_WINDOW_S) {
    return new Response('stale or missing timestamp', { status: 400 });
  }
 
  const expected = createHmac('sha256', SECRET).update(`${ts}.${raw}`).digest('hex');
  const provided =
    sigHeader.split(',').find((p) => p.trim().startsWith('v1='))?.trim().slice(3) ?? '';
 
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(provided, 'hex');
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response('bad signature', { status: 401 });
  }
 
  // Verified — now it's safe to parse.
  const evt = JSON.parse(raw) as {
    event: string;
    tenantId: string | null;
    email: string | null;
    payload: Record<string, unknown>;
  };
 
  switch (evt.event) {
    case 'tenant.deleted':
      // e.g. enqueue a goodbye email to evt.email, then return fast.
      break;
    default:
      // Unknown event — ack and ignore so new event types don't 4xx.
      break;
  }
 
  return new Response(null, { status: 204 });
}

If you use a framework that parses JSON for you (for example Express with express.json()), capture the raw bytes instead — express.raw({ type: 'application/json' }) — or the recomputed HMAC will never match.

Billing webhooks

Subscription and payment events flow into Pluralize from the billing provider (Polar) and are handled for you — there is nothing to configure on your side for those. Read a tenant's current plan and limits with app.billing.getEntitlements() (see Billing); they reflect a completed payment within seconds.