Receive webhooks
Register an HTTPS endpoint, verify every inbound delivery, and react to deposit and connect events in real time. This recipe covers setup, a plain Node.js receiver, idempotency, and local testing.
Time: ~20 minutes
API: POST /api/partner/v1/webhooks (register) · POST your endpoint (receive)
Integration path — recommended order for a new partner integration:
1. Connect → 2. Webhooks (this recipe) → 3. Deposits
The
@gando/partnernpm package is forthcoming (Speakeasy). Examples below reflect the intended SDK API.
Prerequisites
-
Install the SDK
npm install @gando/partner -
Partner API key —
gando_pk_test_…for staging. -
Staging environment —
https://stagingv2.gando.app(production:https://gando.app). -
Public HTTPS URL for your receiver — use ngrok or similar for local dev (see Test locally).
-
Recipe 01 completed (recommended) — subscribe to
rental_operator.linkedto receive the GandoaccountIdas soon as a rental operator activates via Partner Connect. -
Runnable examples repo (optional): gando-partner-js-examples (forthcoming).
Overview
Gando sends JSON POST requests to your endpoint when something changes on a linked rental operator or deposit. Each delivery is signed with HMAC-SHA256. Your server must verify the signature on the raw request body before parsing JSON.
Event types
Authoritative list: types/partner-webhook.ts (PARTNER_WEBHOOK_EVENTS / lib/partners/partner-webhook.zod.ts).
| Event | When it fires |
|---|---|
rental_operator.linked | A rental operator completes partner connect and is linked to your partner |
deposit.status_changed | Wildcard — any deposit status transition (fallback when no more specific event matches) |
deposit.activated | Deposit becomes active (tenant secured the caution) |
deposit.captured | Deposit becomes captured (collection on claim) |
deposit.expired | Deposit becomes close (natural end of contract / expiry) |
deposit.cancelled | Deposit becomes cancelled |
Dispatch rule: if your endpoint is subscribed to both deposit.status_changed and a specific event (e.g. deposit.activated), Gando sends one delivery — the most specific subscribed event wins. You do not receive both for the same transition.
There is no
deposit.refusedevent. A tenant declined by scoring or who abandons checkout is reflected asdeposit.cancelledordeposit.status_changedwithstatuscancelled/payment_issue/incomplete.
Headers on every delivery
| Header | Description |
|---|---|
Content-Type | application/json |
X-Gando-Signature | sha256=<hex> — HMAC over <timestamp>.<rawBody> |
X-Gando-Timestamp | Unix time in seconds |
X-Gando-Event | Event name (e.g. deposit.activated) |
Payload shape (deposit events)
All deposit events share the same JSON structure (DepositStatusChangedPayload in lib/services/webhook-payload.types.ts):
{
"event": "deposit.activated",
"createdAt": "2026-05-20T10:00:00.000Z",
"data": {
"id": "dep_abc123",
"reference": "GAN-001",
"rentalContract": "CTR-2026-042",
"status": "active",
"previousStatus": "pending",
"amountCents": 80000,
"contractStartAt": "2026-04-01T00:00:00.000Z",
"contractEndAt": "2026-04-10T23:59:59.000Z",
"client": {
"id": "cli_xyz",
"email": "tenant@example.com",
"firstName": "Jean",
"lastName": "Dupont"
},
"partnerContext": {
"partnerId": "partner_abc",
"partnerName": "CityRent",
"externalId": "EXT-001"
}
}
}
partnerContext is present only when the deposit was created via the Partner API. client may be null.
Flow
Step 1 — Register your endpoint
Create a webhook subscription with your public URL. The signing secret is returned exactly once — store it immediately.
import { Gando } from '@gando/partner';
const gando = new Gando({
apiKey: process.env.GANDO_API_KEY!,
serverURL: 'https://stagingv2.gando.app',
});
const response = await gando.webhooks.create({
url: 'https://partner.example.com/webhooks/gando',
// Optional — omit to subscribe to all events in PARTNER_WEBHOOK_EVENTS
events: [
'deposit.status_changed',
'deposit.activated',
'deposit.cancelled',
],
});
const webhook = response.object?.data;
// CRITICAL: persist before closing this request handler
const secret = webhook?.secret; // gando_whsec_…
await fs.writeFile('/secure/path/gando_webhook_secret', secret!, { flag: 'wx' });
Response (201):
| Field | Description |
|---|---|
id | Webhook id (pwh_…) |
url | Your endpoint URL |
secret | Signing secret — only returned here and on rotate-secret |
events | Subscribed event types |
Store the secret in your secrets manager or .env as GANDO_WEBHOOK_SECRET. Never commit it to git.
If you lose the secret, call POST /api/partner/v1/webhooks/{id}/rotate-secret — the old secret stops working immediately.
Step 2 — Implement your receiver
Gando considers a delivery successful when your endpoint returns 2xx within 10 seconds. Return 2xx only after you have safely recorded the event (or enqueued it). Slow work belongs in a background job.
Plain Node.js (standalone)
Single-file server suitable for local dev or any Node process that reads the raw body unchanged.
# From the examples repo (or copy the script below)
npm install @gando/partner
cp .env.example .env # set GANDO_WEBHOOK_SECRET=gando_whsec_…
# CLI self-test (signature round-trip)
npx tsx examples/02-webhook-receiver.ts --self-test
# HTTP receiver on port 8787
npx tsx examples/02-webhook-receiver.ts
/**
* Gando partner webhook receiver (plain Node.js).
*
* CLI: npx tsx examples/02-webhook-receiver.ts --self-test
* HTTP: npx tsx examples/02-webhook-receiver.ts
*/
import { createHmac, createHash } from 'node:crypto';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebhookVerifier, WebhookSignatureError } from '@gando/partner';
const PORT = Number(process.env.PORT ?? 8787);
if (process.argv.includes('--self-test')) {
runCliSelfTest();
} else {
createServer(handleRequest).listen(PORT, () => {
console.log(`Webhook receiver listening on http://127.0.0.1:${PORT}`);
});
}
function runCliSelfTest(): void {
const secret = process.env.GANDO_WEBHOOK_SECRET;
if (!secret) {
console.error('Set GANDO_WEBHOOK_SECRET');
process.exit(1);
}
const rawBody = JSON.stringify({
event: 'deposit.status_changed',
createdAt: new Date().toISOString(),
data: {
id: 'dep_example',
reference: 'GAN-EXAMPLE',
status: 'active',
previousStatus: 'pending',
},
});
const timestamp = String(Math.floor(Date.now() / 1000));
const signature =
'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
try {
WebhookVerifier.verify(rawBody, signature, timestamp, secret);
} catch (err) {
const reason = err instanceof WebhookSignatureError ? err.reason : String(err);
console.error(`Verification failed: ${reason}`);
process.exit(1);
}
console.log('Webhook signature verification OK');
console.log(`Start HTTP: npx tsx examples/02-webhook-receiver.ts`);
}
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.method !== 'POST') {
res.writeHead(405, { Allow: 'POST' });
res.end('Method Not Allowed');
return;
}
const secret = process.env.GANDO_WEBHOOK_SECRET;
if (!secret) {
res.writeHead(500);
res.end('Missing GANDO_WEBHOOK_SECRET');
return;
}
// 1. Read raw body BEFORE JSON.parse
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
const rawBody = Buffer.concat(chunks).toString('utf8');
const signature = req.headers['x-gando-signature'] ?? '';
const timestamp = req.headers['x-gando-timestamp'] ?? '';
const event = req.headers['x-gando-event'] ?? '';
// 2. Verify signature (default tolerance: 300 s)
try {
WebhookVerifier.verify(
rawBody,
Array.isArray(signature) ? signature[0]! : signature,
Array.isArray(timestamp) ? timestamp[0]! : timestamp,
secret,
);
} catch {
res.writeHead(400);
res.end();
return;
}
// 3. Idempotency — same bytes on every Gando retry for this delivery
const eventId = createHash('sha256').update(rawBody).digest('hex');
if (await webhookAlreadyProcessed(eventId)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: true, duplicate: true }));
return;
}
// 4. Parse and handle
const payload = JSON.parse(rawBody) as Record<string, unknown>;
try {
handleGandoWebhook(Array.isArray(event) ? event[0]! : event, payload);
await markWebhookProcessed(eventId);
} catch (err) {
console.error('[gando-webhook] handler failed:', err);
res.writeHead(500);
res.end();
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: true }));
}
function handleGandoWebhook(event: string, payload: Record<string, unknown>): void {
const data = payload.data as Record<string, unknown> | undefined;
const depositId = (data?.id as string | undefined) ?? 'unknown';
switch (event) {
case 'deposit.activated':
console.log(`[gando] deposit secured: ${depositId}`);
break;
case 'deposit.cancelled':
console.log(`[gando] deposit cancelled: ${depositId}`);
break;
case 'deposit.captured':
console.log(`[gando] deposit captured: ${depositId}`);
break;
case 'deposit.expired':
console.log(`[gando] deposit expired: ${depositId}`);
break;
case 'rental_operator.linked':
console.log('[gando] rental operator linked');
break;
default:
console.log(`[gando] ${event}: ${depositId}`);
}
}
// Replace with your database — in-memory Set shown for illustration
const processedEventIds = new Set<string>();
async function webhookAlreadyProcessed(eventId: string): Promise<boolean> {
return processedEventIds.has(eventId);
}
async function markWebhookProcessed(eventId: string): Promise<void> {
processedEventIds.add(eventId);
}
WebhookVerifier.verify() implements the same algorithm as Gando's outbound signer: HMAC-SHA256 over {timestamp}.{rawBody} with your gando_whsec_… key.
Idempotency (partner-side)
Gando retries failed deliveries with exponential backoff. The JSON body is identical on every attempt for the same delivery; only X-Gando-Timestamp and X-Gando-Signature change.
If you process an event twice (e.g. your handler crashes after side effects but before the HTTP response), you may double-book a rental or send duplicate emails. Store every processed event id before returning 2xx.
Recommended deduplication key
Use a stable id derived from the verified raw body:
import { createHash } from 'node:crypto';
const eventId = createHash('sha256').update(rawBody).digest('hex');
Gando will ship an X-Gando-Event-Id header (evt_…) in a future release; when present, prefer that value over the body hash.
Example table
CREATE TABLE webhook_events_received (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
// After verify(), before business logic:
if (await repo.exists(eventId)) {
res.writeHead(200);
res.end();
return;
}
await db.transaction(async (tx) => {
await repo.insert(tx, eventId);
await processDeposit(tx, payload);
});
Insert the row in the same transaction as your business update, or enqueue to a queue with the event_id as the message deduplication key.
Replay a delivery
Test delivery (available now)
Send a synthetic deposit.activated event to validate your endpoint end-to-end:
await gando.webhooks.test({ id: webhookId });
Equivalent: POST /api/partner/v1/webhooks/{id}/test. Your endpoint must be subscribed to deposit.activated or deposit.status_changed.
Redeliver a past event (dashboard)
Coming soon. The partner dashboard will let you redeliver a failed delivery from the delivery log without re-triggering the underlying deposit transition.
Until then:
- List deliveries:
GET /api/partner/v1/webhooks/{id}/deliveries - Fix your receiver and use test to confirm verification + handling
- For missed real events, poll
GET /api/partner/v1/deposits/{id}as a backfill
Common pitfalls
| Pitfall | Why it breaks | Fix |
|---|---|---|
JSON.parse before WebhookVerifier.verify() | Signature is computed over raw bytes; re-encoding changes the payload | Read the request stream once, verify, then parse |
| Ignoring timestamp tolerance | Stolen payloads can be replayed indefinitely | Use default 300 s tolerance; reject expired timestamps (WebhookSignatureError reason expired) |
| Return 200 then crash in shutdown handler | Gando marks the delivery delivered; the event is lost | Persist or enqueue before sending 2xx; use transactions |
| Return 200 on handler errors | Same — no retry | Return 500 so Gando schedules a retry |
| Middleware consuming the body | Empty body → signature mismatch | Disable body-parsing middleware on the webhook route |
Storing only X-Gando-Event without dedup | Retries reuse the same body | Dedupe on body hash (or future X-Gando-Event-Id) |
| Wrong secret after rotation | All verifications fail | Update GANDO_WEBHOOK_SECRET when you rotate |
Test locally
-
Start your receiver on port
8787:npx tsx examples/02-webhook-receiver.ts -
Expose with ngrok:
ngrok http 8787 # Copy https://abc123.ngrok-free.app -
Register the ngrok URL on staging (use a small script or the SDK):
const gando = new Gando({ apiKey: process.env.GANDO_API_KEY!, serverURL: 'https://stagingv2.gando.app', }); const { object } = await gando.webhooks.create({ url: 'https://abc123.ngrok-free.app/webhooks/gando', events: ['deposit.activated', 'deposit.status_changed', 'rental_operator.linked'], }); console.log('Webhook secret (store once):', object?.data?.secret);Save the printed
gando_whsec_…into.envasGANDO_WEBHOOK_SECRETand restart your receiver. -
Send a test delivery (use the
pwh_…id from step 3):await gando.webhooks.test({ id: 'pwh_…' }); -
Trigger a real event — complete Recipe 03 — Create a deposit with
depositUrlGeneration: trueand finish tenant checkout on staging. Watch your receiver logs fordeposit.activated. -
Inspect deliveries in the Gando partner dashboard or via
GET /api/partner/v1/webhooks/{id}/deliveries.
Next steps
Continue the integration path:
- Recipe 03 — Create a deposit — create cautions and track
deposit.activatedvia webhooks instead of polling
Earlier in the path:
- Recipe 01 — Link a rental operator — if not done yet; triggers
rental_operator.linked
Reference:
- Partner API reference — full OpenAPI docs
- Webhooks SDK reference — forthcoming with Speakeasy-generated
@gando/partner