Amba

Webhooks

How inbound webhooks authenticate — RevenueCat Bearer token, Superwall HMAC-SHA256, and the generic retry contract.

Amba accepts inbound webhooks from third-party platforms on /webhooks/:provider?project_id=.... Every webhook is signature-verified before the body is parsed, using a constant-time comparison so timing attacks can't probe the secret. Unverified requests get 401.

Today there are two inbound webhook providers: RevenueCat (subscription events) and Superwall (paywall events). Each has its own auth mechanism mirroring the provider's wire format.

RevenueCat

RevenueCat authenticates webhooks with a bearer token you choose at integration-configure time.

Endpoint

POST https://api.amba.dev/webhooks/revenuecat?project_id=proj_xxx
Authorization: Bearer <webhook_secret>

The secret is what you passed as config.webhook_secret to POST /admin/integrations.

Verification (server-side)

const authHeader = c.req.header('Authorization') ?? '';
const expectedHeader = `Bearer ${expectedToken}`;
const a = Buffer.from(authHeader);
const b = Buffer.from(expectedHeader);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
  return c.json({ error: { code: 'INVALID_SIGNATURE', ... } }, 401);
}

Retry contract

On success Amba returns 200 { "data": { "received": true } }. If background processing fails, the endpoint returns 500 so RevenueCat retries per its webhook contract — silent event loss is worse than a visible failure.

Superwall

Superwall signs the raw body with HMAC-SHA256 using a shared secret, and sends the hex digest in x-superwall-signature.

Endpoint

POST https://api.amba.dev/webhooks/superwall?project_id=proj_xxx
x-superwall-signature: <hex_digest>
Content-Type: application/json

Verification (server-side)

const rawBody = await c.req.text();
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const provided = Buffer.from(signatureHeader);
if (provided.length !== expected.length || !timingSafeEqual(provided, Buffer.from(expected))) {
  return c.json({ error: { code: 'INVALID_SIGNATURE', ... } }, 401);
}
// Parse JSON AFTER verification.
const body = JSON.parse(rawBody);

Reading the raw body before JSON parsing is critical — HMAC validates the bytes on the wire, not a re-serialized object. Parsing first would produce a different digest and invalidate every request.

Engagement mirror

Paywall events that include user_id + event are mirrored into your project's engagement_events table as paywall_<event> so paywall interactions feed into streaks, XP rules, and segment membership alongside first-party events. Delivery is deduplicated on the provider's event id, so a redelivered event is recorded once.

Rate limits

Each provider has a per-project, per-second rate limit (bucket keyed by project_id query param):

ProviderLimit
RevenueCat100/sec
Superwall100/sec

Bursts above the limit return 429. Providers batch events before delivery, so sustained load above this ceiling is more likely replay / abuse than normal traffic.

Error codes

CodeHTTPMeaning
MISSING_PROJECT400project_id query param was not provided
NOT_CONFIGURED404No active integration row for this project
INVALID_SIGNATURE401Header missing or failed timingSafeEqual
INVALID_BODY400Malformed JSON (after signature verified)
WEBHOOK_PROCESSING_FAILED500Background processing could not accept the event (retry-safe for RC)

Configuration

Both providers are configured identically, via the admin API:

POST /admin/integrations
 
{
  "provider": "revenuecat",   // or "superwall"
  "config": { "webhook_secret": "<your_secret_here>" }
}

The API responds with a webhook_url containing the exact URL to register in the provider dashboard. Push providers (apns, fcm) don't have webhooks and get no webhook_url — they only send.

Rotating a secret

PATCH /admin/integrations/revenuecat
 
{ "config": { "webhook_secret": "<new_secret>" } }

The new secret takes effect immediately. Update the provider's webhook setting to match before rotating — mismatched secrets produce 401 INVALID_SIGNATURE until both sides agree.

Outbound webhooks

Amba can POST your engagement events to any HTTPS endpoint you own. Each subscription watches for a single event name and sends a signed delivery every time that event fires. You can register multiple subscriptions — one per event, or multiple per endpoint.

Register a subscription

POST /v1/admin/projects/:projectId/webhooks/subscriptions
Authorization: Bearer <developer-token>
 
{
  "event_name": "user.streak_extended",
  "target_url": "https://your-server.example.com/hooks/amba"
}

event_name must match ^[a-z][a-z0-9_.-]{0,127}$. Use the same event names you track via Amba.track().

The response includes a secret — a 64-character hex string used to verify deliveries. Copy it immediately. It is shown exactly once; subsequent GET calls omit it. If you lose it, rotate the secret (see below).

Verify a delivery

Every delivery includes an X-Amba-Signature header with the format t=<unix_seconds>,v1=<hex_hmac>. The HMAC covers ${t}.${rawBody} (period-separated) so both the timestamp and the payload are bound to the signature.

import { createHmac, timingSafeEqual } from 'node:crypto';
 
function verifyAmbaWebhook(
  rawBody: string,
  signatureHeader: string, // X-Amba-Signature header value
  secret: string,
): boolean {
  // Parse t= and v1= segments.
  const parts = Object.fromEntries(signatureHeader.split(',').map((s) => s.split('=')));
  const t = parts['t'];
  const v1 = parts['v1'];
  if (!t || !v1) return false;
 
  // Enforce a ±5-minute replay window.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
 
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest('hex');
  return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
 
// Express example — read rawBody BEFORE parsing JSON.
app.post('/hooks/amba', express.raw({ type: '*/*' }), (req, res) => {
  const sig = req.headers['x-amba-signature'] as string;
  if (!verifyAmbaWebhook(req.body.toString(), sig, process.env.AMBA_WEBHOOK_SECRET!)) {
    return res.status(401).send('invalid signature');
  }
  const event = JSON.parse(req.body.toString());
  // handle event.event_name, event.properties, event.occurred_at …
  res.json({ received: true });
});

Additional headers on every delivery:

HeaderDescription
X-Amba-Signaturet=<unix_seconds>,v1=<hex_hmac> — verify before parsing the body
X-Amba-Idempotency-KeyStable per-event UUID — use it to deduplicate retried deliveries
X-Amba-Event-NameThe event name (same as event_name on the subscription)
X-Amba-Event-IdSource engagement event UUID — cross-reference with analytics
X-Amba-AttemptDelivery attempt counter (starts at 1)

Return 2xx to acknowledge. Non-2xx responses trigger retries with exponential back-off. After all retries are exhausted the delivery enters dlq status and the subscription may enter degraded (circuit breaker).

Manage subscriptions

GET    /v1/admin/projects/:projectId/webhooks/subscriptions
GET    /v1/admin/projects/:projectId/webhooks/subscriptions/:id
PATCH  /v1/admin/projects/:projectId/webhooks/subscriptions/:id
DELETE /v1/admin/projects/:projectId/webhooks/subscriptions/:id

PATCH accepts event_name, target_url, and status ("active" or "disabled"). The degraded status is set automatically by the circuit breaker — re-enable by patching "status": "active".

Rotate the signing secret

POST /v1/admin/projects/:projectId/webhooks/subscriptions/:id/rotate-secret

Returns a new secret. Update your receiver to accept the new secret before rotating — deliveries in flight at the moment of rotation are still signed with the old secret.

Send a test delivery

POST /v1/admin/projects/:projectId/webhooks/subscriptions/:id/test

Fires a synthetic delivery to the subscription's target_url without ingesting a real event or affecting the circuit-breaker state. Use it to confirm your endpoint is reachable before going live.

Delivery history

GET /v1/admin/projects/:projectId/webhooks/deliveries
GET /v1/admin/projects/:projectId/webhooks/deliveries?subscription_id=<id>&status=dlq
GET /v1/admin/projects/:projectId/webhooks/deliveries/:id

Delivery statuses: pending, delivered, failed, dlq. A delivery enters dlq after all retry attempts are exhausted. The detail response includes the full attempt log with HTTP status codes and error messages.

Replay a failed delivery

POST /v1/admin/projects/:projectId/webhooks/deliveries/:id/replay

Re-enqueues a dlq or failed delivery with the original payload and idempotency key. Your receiver sees the same X-Amba-Idempotency-Key as the original delivery, so it can deduplicate correctly.

Next