Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.emergedata.ai/llms.txt

Use this file to discover all available pages before exploring further.

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.