Authentication
Webhook deliveries are signed with HMAC-SHA256. Verify every request before processing. Headers sent by Emerge:| Header | Description |
|---|---|
X-Signature | HMAC-SHA256 signature (hex) |
X-Webhook-Version | Payload/header contract version (2.0) |
X-Attempt-Number | Delivery attempt number (starts at 1) |
Idempotency-Key | Stable id for deduplication |
Event types
| Event | Description |
|---|---|
consent.given | New consent granted |
consent.revoked | Consent revoked |
consent.expiring | Consent nearing expiry |
consent.reauthorized | Previously revoked/expired consent granted again |
data.ready | User’s data has landed and is queryable via the Query API |
data.failed | Data 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
consent.given
{
"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
}
]
}
consent.revoked
{
"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 Walletaccount_deleted— user deleted their account (GDPR right-to-erasure)
consent.expiring
{
"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
}
]
}
consent.reauthorized
{
"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}`);
}
import hashlib
import hmac
import json
import os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
secret = os.getenv("EMERGE_WEBHOOK_SECRET")
if not secret:
raise RuntimeError("Missing EMERGE_WEBHOOK_SECRET")
_seen: set[str] = set()
@app.post("/webhooks/emerge")
async def receive_webhook(request: Request):
body = await request.body()
signature = request.headers.get("x-signature")
idem_key = request.headers.get("idempotency-key")
if not signature or not idem_key:
raise HTTPException(status_code=401, detail="Missing signature or idempotency key")
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="Invalid signature")
if idem_key in _seen:
return {"status": "duplicate_ignored"}
_seen.add(idem_key)
payload = json.loads(body)
event = payload.get("event")
if event == "consent.revoked":
uid = payload.get("uid", "")
for source in payload.get("sources", []):
provider = source.get("provider")
if provider:
await purge_provider_data(uid, provider)
elif event == "data.ready":
uid = payload.get("uid", "")
for source in payload.get("sources", []):
provider = source.get("provider")
if provider:
await enqueue_provider_query(uid, provider)
elif event == "data.failed":
uid = payload.get("uid", "")
for source in payload.get("sources", []):
provider = source.get("provider", "unknown_provider")
error_code = source.get("error_code", "unknown_error")
error_message = source.get("error_message", "No details provided")
await notify_ops(uid, provider, error_code, error_message)
return {"status": "ok"}
async def purge_provider_data(uid: str, provider: str):
print(f"Purging {provider} data for {uid}")
async def enqueue_provider_query(uid: str, provider: str):
print(f"Queueing query for {provider} and user {uid}")
async def notify_ops(uid: str, provider: str, error_code: str, error_message: str):
print(f"Data export failed for {provider} and user {uid}: {error_code} - {error_message}")
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.