---
title: "Webhooks · Fungies"
description: "Real-time payment and subscription events, signed with HMAC-SHA256 and retried until you acknowledge them. Seven event types, at-least-once delivery, and an idempotency key on every one."
image: "https://fungies.io/og-image.jpg"
canonical: "https://fungies.io/webhooks/"
---

Developers · Webhooks

# 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.

[Start free](https://app.fungies.io/register)

[Book a demo](https://calendly.com/duke-vuh/fungies-io-demo-clone)

No upfront costs. No credit card required.

Already selling with Fungies

 [![Leadsin.io](/logos/clients/leadsin.png)](/customer-story/hosted-checkout-solution-for-leadsin-io-a-saas-business "Leadsin.io — read the story")[![RenderAI](/logos/clients/renderai.png) ](/customer-story/helping-renderai-reduce-their-ai-saas-payment-integration-from-days-to-just-a-couple-of-minutes "RenderAI — read the story")[![SuperScale](/logos/clients/superscale.png) ](/customer-story/nimblebits-revenue-boost-with-a-web-store-solution "SuperScale — read the story")[![FreeVocals](/logos/clients/freevocals.png) ](/customer-story/freevocals-com-and-fungies-io-a-seamless-solution-for-digital-e-commerce "FreeVocals — read the story")![STATSCORE](/logos/clients/statscore.png)![SimFabric](/logos/clients/simfabric.png)![BotRix](/logos/clients/botrix.png)![Enhance AI](/logos/clients/enhance-ai.png)![The Humanize AI](/logos/clients/humanize-ai.png)![Kenerate AI](/logos/clients/kenerate.png)

Live

## 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.

Demonstration feed · invented orders and amounts, real event types

One endpoint

## 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.

[Webhooks docs](https://docs.fungies.io/developers/webhooks/overview) [The Event resource](https://docs.fungies.io/core-resources/event) [The JavaScript SDK](/fungies-sdk)

ExpressNext.jsFulfilSubscriptions

```
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

1.  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.
    
2.  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.
    
3.  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.
    
4.  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

01

### 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.

02

### 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.

03

### 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}.

04

### 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

### Which event should I fulfil on?

### How do I verify the signature?

### What happens if my endpoint is down?

### Can the same event arrive twice?

### Do events arrive in the order they happened?

### How do I test this locally?

### Do I still need webhooks if I use the JavaScript SDK?

## 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.

[Start free](https://app.fungies.io/register)

[Book a demo](https://calendly.com/duke-vuh/fungies-io-demo-clone)

No upfront costs. No credit card required.

```json
{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://fungies.io/#organization","name":"Fungies","legalName":"Fungies Inc.","url":"https://fungies.io/","description":"Merchant of Record for SaaS, games, and digital products. Fungies handles global payments, VAT and sales tax, and checkout. Free to start, commission only.","slogan":"Tax-compliant payments for everything digital","logo":{"@type":"ImageObject","url":"https://fungies.io/brand/fungies-logo-black.svg"},"image":"https://fungies.io/og-image.jpg","address":{"@type":"PostalAddress","streetAddress":"2100 Geng Road, Suite 210","addressLocality":"Palo Alto","addressRegion":"CA","postalCode":"94303","addressCountry":"US"},"sameAs":["https://www.facebook.com/fungies.io","https://www.linkedin.com/company/fungies","https://twitter.com/fungies_io","https://t.me/fungies_announcements","https://discord.gg/yfH5ZyTZH4","https://www.youtube.com/@Fungies","https://www.tiktok.com/@fungies_io"],"contactPoint":[{"@type":"ContactPoint","contactType":"customer support","email":"support@fungies.io","url":"https://help.fungies.io/","availableLanguage":["en"]}]},{"@type":"WebSite","@id":"https://fungies.io/#website","name":"Fungies","url":"https://fungies.io/","description":"Merchant of Record for SaaS, games, and digital products. Fungies handles global payments, VAT and sales tax, and checkout. Free to start, commission only.","inLanguage":"en","publisher":{"@id":"https://fungies.io/#organization"}},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://fungies.io/"},{"@type":"ListItem","position":2,"name":"Webhooks","item":"https://fungies.io/webhooks"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Which event should I fulfil on?","acceptedAnswer":{"@type":"Answer","text":"payment_success, and only payment_success. It fires when a payment is confirmed PAID, for the first charge and for every renewal. subscription_created can be emitted while the first payment is still pending, so fulfilling on it hands out access to purchases that have not completed — and sometimes never will."}},{"@type":"Question","name":"How do I verify the signature?","acceptedAnswer":{"@type":"Answer","text":"Compute an HMAC-SHA256 of the raw request body using your webhook secret, prefix the hex digest with sha256_, and compare it against the x-fngs-signature header in constant time. The important word is raw: if your framework has already parsed and re-serialised the JSON, the bytes will differ and every signature will fail."}},{"@type":"Question","name":"What happens if my endpoint is down?","acceptedAnswer":{"@type":"Answer","text":"We retry up to five times. Anything other than a 2xx counts as a failure, as does taking longer than thirty seconds to respond. A short outage is survivable; the reason to acknowledge before processing is that a slow handler looks identical to a broken one from our side."}},{"@type":"Question","name":"Can the same event arrive twice?","acceptedAnswer":{"@type":"Answer","text":"Yes, and you should plan for it. Delivery is at-least-once, so a dropped acknowledgement means a retry for work you already did. Every event carries an idempotencyKey — write it to a table with a unique constraint and return early when the insert conflicts."}},{"@type":"Question","name":"Do events arrive in the order they happened?","acceptedAnswer":{"@type":"Answer","text":"No. Delivery is asynchronous, retried and batched, and the payload has no timestamp you could sort by. Treat each event as a statement about something that happened rather than as the current state, never downgrade a status you have already seen advance, and call GET /v0/subscriptions/{id} when you need the authoritative answer."}},{"@type":"Question","name":"How do I test this locally?","acceptedAnswer":{"@type":"Answer","text":"Expose your development server with ngrok and register the tunnel URL, or point a webhook at a webhook.site bin first and read a few real payloads before writing any handling. Test mode events carry testMode: true, so your handler can tell them apart from live traffic."}},{"@type":"Question","name":"Do I still need webhooks if I use the JavaScript SDK?","acceptedAnswer":{"@type":"Answer","text":"Yes. The SDK fires fungies:checkout:complete in the browser, which is the right way to update your interface. It is the wrong way to grant entitlements, because a customer can close the tab the instant they pay. The browser event updates the screen; the webhook updates the database."}}]}]}
```
