Skip to content

Implementation

How-To Guides

How To Receive FXMacroData Webhooks With Cloudflare Workers

Deploy a Cloudflare Worker as an FXMacroData webhook receiver: read the raw body before parsing, verify the HMAC signature with the Web Crypto API, store releases idempotently in D1, and add a Cron Trigger that polls the changes endpoint as a fallback.

Share article X LinkedIn Email
Pip, the FXMacroData square robot with the logo mark on his torso, receiving a signed webhook delivery and filing it into a D1 table
Pip takes one signed delivery at the edge: verify the signature, store the release on its event ID, answer 2xx.
Quick answer: deploy a Cloudflare Worker as your webhook receiver, read the request body with 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.

Read the raw bytes

The signature covers the exact body we sent. Parse only after you have verified it.

Use Web Crypto

Workers expose crypto.subtle, not require("crypto"). HMAC works the same way.

Commit before 2xx

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-EventEvent name. Currently announcement.
X-FXMD-Event-IDStable event ID. Use it as your idempotency key.
X-FXMD-Delivery-IDUnique ID for this delivery attempt record.
X-FXMD-TimestampUnix timestamp included in the signed message.
X-FXMD-Signaturev1= 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

Watch the delivery log

GET /v1/webhooks/deliveries shows status, attempt count, last status code and last error for recent deliveries.

Re-enable after a fix

Ten consecutive failed deliveries disable a subscription. PATCH it with {"status":"active"} once the endpoint is healthy.

Tail the Worker

npx wrangler tail shows live requests, which is the fastest way to confirm a signature failure.

Answer quickly

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.

FXMacroData API data

Data endpoints used in this article

No FXMacroData API data endpoint is attributed to this article. Its evidence base is identified in the article and source links.

Explore the FXMacroData API reference

Frequently asked

Questions about this topic

How do I verify an FXMacroData webhook signature in a Cloudflare Worker?

Read the request body once with await request.text(), verify the X-FXMD-Signature header against that exact string using the Web Crypto API, then parse it. Parsing before verifying changes the bytes and every signature check fails.

Can I use a workers.dev URL as an FXMacroData webhook destination?

Yes. A *.workers.dev hostname is a public HTTPS endpoint and satisfies the destination rules. Attach a custom domain if you want the receiver to outlive the Worker's current account.

Should a Cloudflare Worker return 200 before processing the webhook?

Only for work you can afford to lose. A 2xx response marks the event accepted and it will not be redelivered, so write the event to storage before responding and keep waitUntil for optional follow-up.

What happens if my Worker is down when a release lands?

Add a Cron Trigger that polls /v1/announcements/changes with a stored cursor. A cursor does not expire when your endpoint does, so it recovers releases missed while the Worker was failing.

Why does my signature check fail even though the secret is right?

Almost always because the body was parsed before it was verified. JSON.parse followed by JSON.stringify produces different bytes from the ones that were signed. Read the body once with await request.text(), verify that string, and parse it afterwards.

Do I need D1, or can I use KV?

KV works if you only need deduplication: write the event_id as a key and skip anything that already exists. D1 is the better default when your product needs to query recent releases by currency, indicator or date.

Keep reading

Blogroll

AI Answer-Ready

Key Facts

Page
How To Receive FXMacroData Webhooks With Cloudflare Workers
Section
Articles
Canonical URL
https://fxmacrodata.com/articles/how-to-receive-fxmacrodata-webhooks-with-cloudflare-workers
Source
FXMacroData editorial and official publisher references
Last Updated
2026-10-07 04:37 UTC

Provenance And Trust

Cite the canonical URL and source field above. Where available, this page maps to official publisher releases and timestamped updates.

Quick Q&A

How do I verify an FXMacroData webhook signature in a Cloudflare Worker? Read the request body once with await request.text(), verify the X-FXMD-Signature header against that exact string using the Web Crypto API, then parse it. Parsing before verifying changes the bytes and every signature check fails.

Can I use a workers.dev URL as an FXMacroData webhook destination? Yes. A *.workers.dev hostname is a public HTTPS endpoint and satisfies the destination rules. Attach a custom domain if you want the receiver to outlive the Worker's current account.

Should a Cloudflare Worker return 200 before processing the webhook? Only for work you can afford to lose. A 2xx response marks the event accepted and it will not be redelivered, so write the event to storage before responding and keep waitUntil for optional follow-up.

What happens if my Worker is down when a release lands? Add a Cron Trigger that polls /v1/announcements/changes with a stored cursor. A cursor does not expire when your endpoint does, so it recovers releases missed while the Worker was failing.

Prompt Packs

Use these in ChatGPT, Claude, Gemini, Mistral, Perplexity, or Grok for consistent source-aware outputs.

Share page X LinkedIn Email