await request.text() before parsing it, verify the X-FXMD-Signature header with the Web Crypto API, and write the event into D1 keyed on event_id before returning 200. Add a Cron Trigger that polls /v1/announcements/changes so a missed callback still lands.
A webhook receiver needs one thing: a public HTTPS URL that is always reachable and answers quickly. That is exactly what a Cloudflare Worker is. There is no server to keep alive, the endpoint is global, and a deployed Worker already sits on an HTTPS hostname that satisfies the FXMacroData destination rules with no extra DNS work.
This guide covers the Worker-specific parts of that integration: reading the raw body before parsing, verifying the HMAC signature with crypto.subtle rather than Node's crypto module, storing events idempotently in D1, and using a Cron Trigger as the fallback path. If you are building on Supabase instead, the Supabase Edge Functions guide covers the same job on that runtime.
The signature covers the exact body we sent. Parse only after you have verified it.
Workers expose crypto.subtle, not require("crypto"). HMAC works the same way.
A 200 means accepted. Write the row first, then answer.
1. What the Worker Has to Do
Every FXMacroData delivery is a POST carrying one macro release. The Worker's job is small and fixed:
| Step | Why it matters on Workers |
|---|---|
| Read the raw body | A Request body can only be consumed once, and re-serialising parsed JSON changes the bytes the signature was computed over. |
| Verify the signature | Anyone can POST to a public URL. The HMAC is what makes the endpoint trustworthy. |
| Deduplicate on the event ID | Retries are expected: a failed delivery is retried up to 8 times with a backoff that starts at about one second and caps at 60 seconds. The same event_id can arrive more than once. |
| Answer 2xx | A 2xx marks the delivery accepted. A timeout, HTTP 408, 409, 425, 429 or any 5xx is retried; any other 4xx is a final failure. After 10 consecutive failed deliveries the subscription is disabled. |
The body looks like this. The fields match the release events used elsewhere in the API, so the same handler shape works for the SSE stream and the changes endpoint:
{
"id": "usd_inflation_1772109000",
"event": "announcement",
"created": 1772109002,
"subscription_id": "sub_9b4f5a",
"delivery_id": "sub_9b4f5a_usd_inflation_1772109000",
"data": {
"event_id": "usd_inflation_1772109000",
"currency": "usd",
"indicator": "inflation",
"records_written": 1,
"latest_announcement": {
"date": "2026-06-12",
"val": 3.0,
"announcement_datetime": 1781267400
}
}
}
announcement_datetime is a Unix epoch in seconds, the same form the REST announcement endpoints return, so store it as an integer rather than parsing it as a date string.
2. Create the Worker and Its Bindings
Start a Worker with a D1 database for the event table. D1 gives you a primary key constraint, which is the cheapest possible deduplication.
npm create cloudflare@latest fxmd-webhooks
cd fxmd-webhooks
npx wrangler d1 create fxmd
name = "fxmd-webhooks"
main = "src/index.js"
compatibility_date = "2026-01-01"
[[d1_databases]]
binding = "DB"
database_name = "fxmd"
database_id = "PASTE_THE_ID_WRANGLER_PRINTED"
[triggers]
crons = ["*/5 * * * *"]
create table if not exists fxmd_macro_events (
event_id text primary key,
delivery_id text,
currency text not null,
indicator text not null,
observation_date text,
value real,
announced_at integer,
received_at text not null
);
create index if not exists fxmd_macro_events_pair
on fxmd_macro_events (currency, indicator, observation_date desc);
create table if not exists fxmd_cursors (
name text primary key,
cursor text not null
);
The second table holds the changes-endpoint cursor used by the Cron Trigger fallback in step 6.
Both secrets stay server-side. Never put either in wrangler.toml, because that file is committed:
npx wrangler secret put FXMD_WEBHOOK_SECRET
npx wrangler secret put FXMD_API_KEY
3. Verify the Signature with Web Crypto
FXMacroData signs the timestamp, the event ID, and the request body with your subscription signing secret. Five headers arrive with every delivery:
| Header | Meaning |
|---|---|
X-FXMD-Event | Event name. Currently announcement. |
X-FXMD-Event-ID | Stable event ID. Use it as your idempotency key. |
X-FXMD-Delivery-ID | Unique ID for this delivery attempt record. |
X-FXMD-Timestamp | Unix timestamp included in the signed message. |
X-FXMD-Signature | v1= followed by the HMAC-SHA256 hex digest. |
The signed message is {timestamp}.{event_id}.{raw_body}. Workers have no Node crypto module, so use the Web Crypto API:
const encoder = new TextEncoder();
async function verifySignature(headers, rawBody, secret) {
const timestamp = headers.get("X-FXMD-Timestamp");
const eventId = headers.get("X-FXMD-Event-ID");
const supplied = (headers.get("X-FXMD-Signature") || "").replace(/^v1=/, "");
if (!timestamp || !eventId || !supplied) return false;
// Reject stale deliveries so a captured request cannot be replayed later.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const mac = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(`${timestamp}.${eventId}.${rawBody}`),
);
const expected = [...new Uint8Array(mac)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
return timingSafeEqual(supplied, expected);
}
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let difference = 0;
for (let i = 0; i < a.length; i += 1) {
difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return difference === 0;
}
The comparison is deliberately not ===. A short-circuiting string comparison leaks, through timing, how many leading characters of a guess were correct.
4. Store the Event Idempotently
Read the body once as text, verify it, then parse. Doing it in the other order silently breaks every signature check, because the JSON you re-serialise will not match the bytes that were signed.
export default {
async fetch(request, env) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
// Read the raw bytes first. A Request body is consumed once, and the
// signature covers exactly these bytes.
const rawBody = await request.text();
if (!(await verifySignature(request.headers, rawBody, env.FXMD_WEBHOOK_SECRET))) {
return new Response("Invalid signature", { status: 401 });
}
const payload = JSON.parse(rawBody);
await storeRelease(env, payload.data || {}, payload.delivery_id ?? null);
return new Response("ok", { status: 200 });
},
};
// Shared by the webhook handler and the Cron Trigger fallback in step 6.
// "insert or ignore" makes a redelivered event a no-op rather than a
// duplicate row or a constraint error.
async function storeRelease(env, release, deliveryId = null) {
const latest = release.latest_announcement || {};
await env.DB.prepare(
`insert or ignore into fxmd_macro_events
(event_id, delivery_id, currency, indicator,
observation_date, value, announced_at, received_at)
values (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
release.event_id,
deliveryId,
release.currency ?? "",
release.indicator ?? "",
latest.date ?? null,
latest.val ?? null,
latest.announcement_datetime ?? null,
new Date().toISOString(),
)
.run();
}
Note the ordering: the row is committed before the 200 is returned. It is tempting to answer immediately and finish the work in ctx.waitUntil(), but a 2xx tells FXMacroData the event was accepted and it will not be sent again. Keep waitUntil for genuinely optional follow-up work such as cache purges or notifying your own downstream services.
5. Register the Subscription
Deploy first, so the URL exists before you point a subscription at it:
npx wrangler deploy
A deployed Worker is reachable at https://<worker-name>.<your-subdomain>.workers.dev, or at a custom domain if you have attached one. Either satisfies the destination rules: a public HTTPS host, no embedded credentials, no fragment. Localhost, private addresses and plain HTTP are rejected.
curl -X POST "https://api.fxmacrodata.com/v1/webhooks/subscriptions" \
-H "X-API-Key: $FXMD_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://fxmd-webhooks.YOUR-SUBDOMAIN.workers.dev/",
"events": ["announcement"],
"currencies": ["usd"],
"description": "Cloudflare Worker receiver"
}'
The response contains a signing_secret. It is returned when the subscription is created and when you rotate it, and never again, so store it straight away with wrangler secret put FXMD_WEBHOOK_SECRET. Leave currencies and indicators out entirely if you want everything; an empty filter means no filter.
Rotate the secret without recreating the subscription:
curl -X POST "https://api.fxmacrodata.com/v1/webhooks/subscriptions/SUBSCRIPTION_ID/rotate-secret" \
-H "X-API-Key: $FXMD_API_KEY"
6. Add a Cron Trigger Fallback
Webhooks are push, so a receiver that was down during a release does not get a second chance forever. The changes endpoint is the recovery path: it is cursor-based, so one call returns everything you have not seen across every currency and indicator you asked for.
The Cron Trigger declared earlier calls the scheduled handler in the same Worker. Keep the cursor in D1 alongside the events:
export default {
async fetch(request, env) {
/* the handler from step 4 */
},
async scheduled(event, env, ctx) {
ctx.waitUntil(drainChanges(env));
},
};
async function drainChanges(env) {
const row = await env.DB.prepare(
"select cursor from fxmd_cursors where name = 'usd'",
).first();
const url = new URL("https://api.fxmacrodata.com/v1/announcements/changes");
url.searchParams.set("currencies", "usd");
url.searchParams.set("limit", "100");
if (row?.cursor) url.searchParams.set("since", row.cursor);
const response = await fetch(url, {
headers: { "X-API-Key": env.FXMD_API_KEY },
});
if (!response.ok) return;
const body = await response.json();
// Each change event has the same event_id, currency, indicator and
// latest_announcement fields as a webhook payload's "data" object, so the
// insert-or-ignore write from step 4 stores a release that arrived both
// ways exactly once.
for (const change of body.data || []) {
await storeRelease(env, change);
}
if (body.next_cursor) {
await env.DB.prepare(
`insert into fxmd_cursors (name, cursor) values ('usd', ?)
on conflict(name) do update set cursor = excluded.cursor`,
)
.bind(body.next_cursor)
.run();
}
}
Because both paths write with insert or ignore on the same primary key, overlap is harmless. That is the whole point of keying on event_id.
Workers are the wrong place for the third delivery option. SSE streams expect a process that holds a connection open indefinitely and reconnects with Last-Event-ID, which is not what a request-scoped Worker is for. If you want a live stream, run it in an always-on process and write into the same table.
7. Operate It
GET /v1/webhooks/deliveries shows status, attempt count, last status code and last error for recent deliveries.
Ten consecutive failed deliveries disable a subscription. PATCH it with {"status":"active"} once the endpoint is healthy.
npx wrangler tail shows live requests, which is the fastest way to confirm a signature failure.
Deliveries time out after 10 seconds. Store the event and return; do the analysis later.
Get Started
Deploy the Worker, create the subscription, and confirm the first delivery with npx wrangler tail. Add the Cron Trigger once the push path is working, so the two write into the same table and cover each other.
Reference pages: FXMacroData webhooks, announcement endpoints, SSE streams, and Cloudflare Workers documentation.