Skip to main content
Use Link webhooks to track consent changes and data export status per provider.

Authentication

Webhook deliveries are signed with HMAC-SHA256. Verify every request before processing. Headers sent by Emerge:
HeaderDescription
X-SignatureHMAC-SHA256 signature (hex)
X-Webhook-VersionPayload/header contract version (2.0)
X-Attempt-NumberDelivery attempt number (starts at 1)
Idempotency-KeyStable id for deduplication

Event types

EventDescription
consent.givenNew consent granted
consent.revokedConsent revoked
consent.expiringConsent nearing expiry
consent.reauthorizedPreviously revoked/expired consent granted again
data.readyUser’s data has landed and is queryable via the Query API
data.failedData export failed — data will not be available via the Query API

Payload format (v2)

All webhook events use:
{
  "event": "consent.given",
  "timestamp": "2026-02-12T09:10:11.000000+00:00",
  "uid": "psub_d4e5f6789012345678901234abcdef01",
  "client_id": "ck_live_123456789",
  "sources": []
}
sources[] is event-specific:
  • consent.given:
    • provider, scopes[], valid_until, is_reauthorized
  • consent.revoked:
    • provider, revoked_at, reason (user_revoked | account_deleted)
  • consent.expiring:
    • provider, valid_until, days_until_expiry
  • consent.reauthorized:
    • provider, scopes[], valid_until, is_returning_user
  • data.ready:
    • provider
  • data.failed:
    • provider, error_code, error_message

Response examples

{
  "event": "consent.given",
  "timestamp": "2026-02-12T09:10:11.000000+00:00",
  "uid": "psub_d4e5f6789012345678901234abcdef01",
  "client_id": "ck_live_123456789",
  "sources": [
    {
      "provider": "google_data",
      "scopes": [
        "https://www.googleapis.com/auth/dataportability.myactivity.search",
        "https://www.googleapis.com/auth/dataportability.chrome.history"
      ],
      "valid_until": "2026-08-11T09:10:11.000000+00:00",
      "is_reauthorized": false
    },
    {
      "provider": "gmail",
      "scopes": [
        "https://www.googleapis.com/auth/gmail.readonly"
      ],
      "valid_until": "2026-08-11T09:10:11.000000+00:00",
      "is_reauthorized": false
    }
  ]
}
{
  "event": "consent.revoked",
  "timestamp": "2026-02-12T09:22:44.000000+00:00",
  "uid": "psub_d4e5f6789012345678901234abcdef01",
  "client_id": "ck_live_123456789",
  "sources": [
    {
      "provider": "gmail",
      "revoked_at": "2026-02-12T09:22:44.000000+00:00",
      "reason": "user_revoked"
    }
  ]
}
reason values:
  • user_revoked — user manually revoked consent via the Data Wallet
  • account_deleted — user deleted their account (GDPR right-to-erasure)
{
  "event": "consent.expiring",
  "timestamp": "2026-02-12T09:22:44.000000+00:00",
  "uid": "psub_d4e5f6789012345678901234abcdef01",
  "client_id": "ck_live_123456789",
  "sources": [
    {
      "provider": "google_data",
      "valid_until": "2026-02-15T09:22:44.000000+00:00",
      "days_until_expiry": 3
    }
  ]
}
{
  "event": "consent.reauthorized",
  "timestamp": "2026-02-12T09:22:44.000000+00:00",
  "uid": "psub_d4e5f6789012345678901234abcdef01",
  "client_id": "ck_live_123456789",
  "sources": [
    {
      "provider": "google_data",
      "scopes": [
        "https://www.googleapis.com/auth/dataportability.myactivity.search"
      ],
      "valid_until": "2026-08-11T09:22:44.000000+00:00",
      "is_returning_user": true
    }
  ]
}

data.ready

{
  "event": "data.ready",
  "timestamp": "2026-02-12T09:34:10.000000+00:00",
  "uid": "psub_d4e5f6789012345678901234abcdef01",
  "client_id": "ck_live_123456789",
  "sources": [
    {
      "provider": "google_data"
    }
  ]
}

data.failed

{
  "event": "data.failed",
  "timestamp": "2026-02-12T09:35:41.000000+00:00",
  "uid": "psub_d4e5f6789012345678901234abcdef01",
  "client_id": "ck_live_123456789",
  "sources": [
    {
      "provider": "google_data",
      "error_code": "export_provider_error",
      "error_message": "Provider export request failed. Retry scheduled."
    }
  ]
}

Handling webhooks

import crypto from "crypto";
import express from "express";

const app = express();
app.use(express.raw({ type: "application/json" }));

const secret = process.env.EMERGE_WEBHOOK_SECRET;
if (!secret) {
  throw new Error("Missing EMERGE_WEBHOOK_SECRET");
}

app.post("/webhooks/emerge", async (req, res) => {
  try {
    const signature = req.header("x-signature");
    const idempotencyKey = req.header("idempotency-key");

    if (!signature || !idempotencyKey) {
      return res.status(401).send("Missing signature or idempotency key");
    }

    const expected = crypto.createHmac("sha256", secret).update(req.body).digest("hex");
    const valid =
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature, "hex"), Buffer.from(expected, "hex"));

    if (!valid) {
      return res.status(401).send("Invalid signature");
    }

    // Idempotency guard (replace with DB/Redis in production)
    if (seen(idempotencyKey)) {
      return res.status(200).send("Duplicate ignored");
    }
    remember(idempotencyKey);

    const payload = JSON.parse(req.body.toString("utf8")) as {
      event: string;
      uid: string;
      sources: Array<Record<string, unknown>>;
    };

    if (payload.event === "consent.revoked") {
      await handleRevocation(payload.uid, payload.sources);
    } else if (payload.event === "data.ready") {
      await handleDataReady(payload.uid, payload.sources);
    } else if (payload.event === "data.failed") {
      await handleDataFailed(payload.uid, payload.sources);
    }

    return res.status(200).send("OK");
  } catch (error) {
    console.error("Webhook processing failed", error);
    return res.status(500).send("Server error");
  }
});

const idempotencyStore = new Set<string>();
function seen(key: string): boolean {
  return idempotencyStore.has(key);
}
function remember(key: string): void {
  idempotencyStore.add(key);
}

async function handleRevocation(uid: string, sources: Array<Record<string, unknown>>) {
  for (const source of sources) {
    const provider = String(source.provider ?? "");
    if (provider) {
      await purgeProviderData(uid, provider);
    }
  }
}

async function handleDataReady(uid: string, sources: Array<Record<string, unknown>>) {
  for (const source of sources) {
    const provider = String(source.provider ?? "");
    if (provider) {
      console.log(`Data ready for ${provider} and user ${uid}`);
      await enqueueProviderQuery(uid, provider);
    }
  }
}

async function handleDataFailed(uid: string, sources: Array<Record<string, unknown>>) {
  for (const source of sources) {
    const provider = String(source.provider ?? "unknown_provider");
    const errorCode = String(source.error_code ?? "unknown_error");
    const errorMessage = String(source.error_message ?? "No details provided");
    console.error(
      `Data export failed for ${provider} and user ${uid}: ${errorCode} - ${errorMessage}`
    );
    await notifyOps(uid, provider, errorCode, errorMessage);
  }
}

async function purgeProviderData(uid: string, provider: string) {
  console.log(`Purging ${provider} data for ${uid}`);
}

async function enqueueProviderQuery(uid: string, provider: string) {
  console.log(`Queueing query for ${provider} and user ${uid}`);
}

async function notifyOps(
  uid: string,
  provider: string,
  errorCode: string,
  errorMessage: string
) {
  console.log(`Notify ops: ${uid} ${provider} ${errorCode} ${errorMessage}`);
}

Delivery and retries

  • A non-2xx response triggers retries with exponential backoff.
  • Current max attempts: 5.
  • Always design handlers to be idempotent using Idempotency-Key.