Gando

Gando

Developer Portal

gando.app
All recipes

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. Connect2. Webhooks (this recipe)3. Deposits

The @gando/partner npm package is forthcoming (Speakeasy). Examples below reflect the intended SDK API.


Prerequisites

  1. Install the SDK

    npm install @gando/partner
  2. Partner API keygando_pk_test_… for staging.

  3. Staging environmenthttps://stagingv2.gando.app (production: https://gando.app).

  4. Public HTTPS URL for your receiver — use ngrok or similar for local dev (see Test locally).

  5. Recipe 01 completed (recommended) — subscribe to rental_operator.linked to receive the Gando accountId as soon as a rental operator activates via Partner Connect.

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

EventWhen it fires
rental_operator.linkedA rental operator completes partner connect and is linked to your partner
deposit.status_changedWildcard — any deposit status transition (fallback when no more specific event matches)
deposit.activatedDeposit becomes active (tenant secured the caution)
deposit.capturedDeposit becomes captured (collection on claim)
deposit.expiredDeposit becomes close (natural end of contract / expiry)
deposit.cancelledDeposit 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.refused event. A tenant declined by scoring or who abandons checkout is reflected as deposit.cancelled or deposit.status_changed with status cancelled / payment_issue / incomplete.

Headers on every delivery

HeaderDescription
Content-Typeapplication/json
X-Gando-Signaturesha256=<hex> — HMAC over <timestamp>.<rawBody>
X-Gando-TimestampUnix time in seconds
X-Gando-EventEvent 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

Loading diagram…

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):

FieldDescription
idWebhook id (pwh_…)
urlYour endpoint URL
secretSigning secret — only returned here and on rotate-secret
eventsSubscribed 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:

  1. List deliveries: GET /api/partner/v1/webhooks/{id}/deliveries
  2. Fix your receiver and use test to confirm verification + handling
  3. For missed real events, poll GET /api/partner/v1/deposits/{id} as a backfill

Common pitfalls

PitfallWhy it breaksFix
JSON.parse before WebhookVerifier.verify()Signature is computed over raw bytes; re-encoding changes the payloadRead the request stream once, verify, then parse
Ignoring timestamp toleranceStolen payloads can be replayed indefinitelyUse default 300 s tolerance; reject expired timestamps (WebhookSignatureError reason expired)
Return 200 then crash in shutdown handlerGando marks the delivery delivered; the event is lostPersist or enqueue before sending 2xx; use transactions
Return 200 on handler errorsSame — no retryReturn 500 so Gando schedules a retry
Middleware consuming the bodyEmpty body → signature mismatchDisable body-parsing middleware on the webhook route
Storing only X-Gando-Event without dedupRetries reuse the same bodyDedupe on body hash (or future X-Gando-Event-Id)
Wrong secret after rotationAll verifications failUpdate GANDO_WEBHOOK_SECRET when you rotate

Test locally

  1. Start your receiver on port 8787:

    npx tsx examples/02-webhook-receiver.ts
  2. Expose with ngrok:

    ngrok http 8787
    # Copy https://abc123.ngrok-free.app
  3. 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 .env as GANDO_WEBHOOK_SECRET and restart your receiver.

  4. Send a test delivery (use the pwh_… id from step 3):

    await gando.webhooks.test({ id: 'pwh_…' });
  5. Trigger a real event — complete Recipe 03 — Create a deposit with depositUrlGeneration: true and finish tenant checkout on staging. Watch your receiver logs for deposit.activated.

  6. Inspect deliveries in the Gando partner dashboard or via GET /api/partner/v1/webhooks/{id}/deliveries.


Next steps

Continue the integration path:

  1. Recipe 03 — Create a deposit — create cautions and track deposit.activated via webhooks instead of polling

Earlier in the path:

Reference:

  • Partner API reference — full OpenAPI docs
  • Webhooks SDK reference — forthcoming with Speakeasy-generated @gando/partner