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().

target_url must be a public https:// endpoint. Amba refuses loopback, private-network, link-local, carrier-grade NAT, multicast, and cloud metadata destinations (including names that resolve to them) with 400 TARGET_URL_NOT_ALLOWED, and plain http:// with 400 TARGET_URL_NOT_HTTPS. The same check runs when you update or test a subscription and again before every delivery. To receive webhooks on your laptop, expose your local server through a public HTTPS tunnel.

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. Amba does not follow redirects: a 3xx response counts as a failed attempt, so register the final URL. Amba reads at most 64 KB of your response body and records the start of an error body in the delivery log. 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