Your server finds out the moment the money does
Polling an API is asking the same question until the answer changes. Webhooks turn it around: a payment clears, a subscription renews, a card is declined, and we post the event to your endpoint — signed, retried, and carrying an idempotency key so running your handler twice costs nothing.
This is what the endpoint sees
Every event carries the same envelope: an ID, a type, an idempotency key, a test-mode flag, and a data object holding whichever resources the event concerns. Read the type, act on the data, answer 2xx.
An illustration of webhook traffic: payment and subscription events arriving one after another, each with the JSON body Fungies posts to your endpoint. The same events and payload shape are described in full below.
Roughly thirty lines, and most of them are the signature check
Verify the header, answer immediately, then do the work. The thirty-second timeout is generous until your fulfilment logic calls three services of its own, so acknowledge first and process after.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.FUNGIES_WEBHOOK_SECRET;
/** The digest covers the raw bytes, so the body must not be parsed first. */
const isFromFungies = (raw, header) => {
const expected =
"sha256_" + crypto.createHmac("sha256", SECRET).update(raw).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
// Length check first: timingSafeEqual throws on a mismatch rather than
// returning false, and an attacker controls the header's length.
return a.length === b.length && crypto.timingSafeEqual(a, b);
};
app.post(
"/webhooks/fungies",
express.raw({ type: "application/json" }),
(req, res) => {
if (!isFromFungies(req.body, req.get("x-fngs-signature")))
return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// Acknowledge inside the timeout, then do the slow part off the request.
res.json({ received: true });
queue.add("fungies-event", event);
},
);Four steps to a live endpoint
- 01
Stand up a URL that answers 2xx
It has to accept POST with a JSON body, be reachable over HTTPS in production, and return a success status quickly. During development, point the webhook at an ngrok tunnel or a webhook.site bin and watch real payloads arrive before you write a line of handling.
- 02
Create the webhook in the dashboard
Three fields: your URL, a secret of your choosing used to sign every event, and the event types you want. One endpoint can take all seven, or you can run several endpoints with different subscriptions. Store the secret somewhere it will not end up in a repository.
- 03
Verify the signature before you trust the body
Every request carries x-fngs-signature — an HMAC-SHA256 digest, prefixed sha256_. Recompute it over the raw request body with your secret and compare the two in constant time. Without this, your endpoint will happily grant access to anyone who guesses the URL.
- 04
Fulfil from payment_success, and make it idempotent
payment_success is the paid signal for both first charges and renewals. Delivery is at-least-once, so record the idempotencyKey and return early if you have seen it before. Granting the same licence twice is the cheapest bug on this list to prevent and the most annoying to unpick.
The seven events
- payment_success
- A payment has been processed and is PAID. This is the one to fulfil on — first charges and renewals both arrive as payment_success, so a handler that only listens for this still catches every paid moment.
- payment_refunded
- A payment has been refunded, in full or in part. Revoke access, adjust seats, or write the reversal into your own ledger.
- payment_failed
- An attempt did not go through. Usually a card problem rather than a decision, so it is a prompt to email the customer, not to delete their account.
- subscription_created
- A subscription record now exists. It can be emitted while the first payment is still PENDING, which is why it is informational rather than a licence to fulfil.
- subscription_interval
- A billing interval has been charged. Useful for renewal accounting when you want the recurring charge separated from the first one.
- subscription_updated
- Something about the subscription changed — plan, seat count, status. Treat it as a prompt to re-read the subscription rather than as the new truth.
- subscription_cancelled
- The subscription has been cancelled. Whether access ends now or at the end of the paid period is your product's decision, not ours.
The delivery contract, and what it asks of you
At-least-once, never exactly-once
A network can drop an acknowledgement after your handler has already run, so the same event can arrive twice. Every event carries an idempotencyKey for exactly this reason: store it, check it, and a duplicate becomes a no-op instead of a second charge on your side of the ledger.
Five retries and a thirty-second timeout
Anything other than a 2xx is treated as a failure and retried up to five times. Thirty seconds sounds like plenty until your handler calls a mail provider and a licence server. Answer the request first, queue the work second, and the timeout stops being something you think about.
No order, and no timestamp to impose one
Delivery is asynchronous and batched, so subscription_created and payment_success race each other and the payload carries nothing to sort by. Never downgrade a subscription you have already seen active because a late event says incomplete — and when the current state genuinely matters, read it back with GET /v0/subscriptions/{id}.
Signed, so the URL is not the secret
The endpoint is public by definition. The HMAC-SHA256 signature is what separates an event from us and a POST from someone who found the path in a browser's network tab, which is why verification is the one step in this list that is not optional in production.
Before you go live
Point a webhook at a tunnel and watch one arrive
Create an endpoint, take a test-mode payment, and read the payload before you write any handling. No monthly fee and no card needed to get that far.





