Payment webhooks
Being rolled out. The interface described here is the one of the next release: it may still change, and may not be enabled on your tenant yet.
What it is for
A BillingWebhook is an address of your application that the platform calls on every
payment event: payment succeeded, subscription past due, invoice issued, refund… From these
events your application updates its orders and writes to your customers: the platform
sends no e-mail to your customers.
Prerequisites
- A payment account.
- A public
https://address in your application that acceptsPOST.
1. Create the signing secret
Every call is signed with a secret that only you and the platform know. Have it generated like any other secret (see Create and read a secret):
sng secret create shop-billing-webhook --length 48 --contains letters,numbers
sng secret get shop-billing-webhook --key value # to give to your application
Pass it to your application like your other secrets (see Inject a secret into an App).
2. Declare the webhook
apiVersion: billing.stackngo.io/v1alpha1
kind: BillingWebhook
metadata:
name: shop
spec:
url: https://shop.acme.example/hooks/billing # https required
events:
- payment.succeeded
- payment.failed
- charge.action_required
- charge.failed
- subscription.past_due
- subscription.canceled
- invoice.finalized
- refund.succeeded
secretRef: { name: shop-billing-webhook, key: value } # optional
enabled: true
Without secretRef, a signing secret is generated for you in the secret
<name>-billing-webhook (key secret). If you provide your own, it must be at least 16
characters long.
sng apply billing/webhook.yml
You can declare several BillingWebhooks (one address per application, for example).
The events
| Event | When |
|---|---|
payment.succeeded | a cart was paid |
payment.failed | a payment on the payment page failed |
charge.succeeded | a pay-as-you-go charge succeeded |
charge.action_required | a charge requires strong authentication from your customer (action_url) |
charge.failed | a charge was declined |
subscription.created | a subscription started (first period paid) |
subscription.renewed | a period was charged |
subscription.past_due | a period failed, retries in progress |
subscription.unpaid | retries exhausted, subscription unpaid |
subscription.canceled | subscription cancelled |
invoice.finalized | an invoice or a credit note was issued |
refund.succeeded | a refund was confirmed |
refund.failed | a refund was declined |
The body is a JSON document of the form:
{
"id": "evt_01j9zq…",
"type": "payment.succeeded",
"data": { "id": "pay_01j9zq…", "status": "succeeded", "amount": "58.80", "currency": "EUR",
"customer_id": "cus_…", "mode": "one_time", "tags": { "service": "hosting" }, "…": "…" },
"created_at": "2026-10-08T10:00:00Z"
}
data is the object concerned (payment, charge, subscription, invoice or refund), in the
same form as in the API. The id is also sent in the Billing-Event-Id header and stays the
same when a delivery is retried: use it to ignore duplicates. A delivery that does not get a
2xx response is retried with an increasing delay.
3. Verify the signature
Every call carries the header:
Billing-Signature: t=1791450000,v1=5f8a…e21c
t: the sending time, in Unix seconds;v1:HMAC-SHA256(secret, t + "." + body)in hexadecimal, computed on the raw body, byte for byte.
Check, before parsing the JSON: that t is less than 5 minutes old (against replay), then
that the signature matches, with a constant-time comparison.
Go
package billinghook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
// Verify checks the Billing-Signature header of a raw body.
func Verify(secret []byte, header string, body []byte, now time.Time) error {
var ts string
var sigs []string
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch k {
case "t":
ts = v
case "v1":
sigs = append(sigs, v)
}
}
if ts == "" || len(sigs) == 0 {
return errors.New("incomplete Billing-Signature header")
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return errors.New("invalid timestamp")
}
if d := now.Sub(time.Unix(sec, 0)); d > tolerance || d < -tolerance {
return errors.New("timestamp outside tolerance")
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "."))
mac.Write(body)
want := mac.Sum(nil)
for _, s := range sigs {
got, err := hex.DecodeString(s)
if err == nil && hmac.Equal(got, want) { // constant time
return nil
}
}
return errors.New("invalid signature")
}
// Handler reads the raw body, verifies it, then hands it to process.
func Handler(secret []byte, process func([]byte) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
if err := Verify(secret, r.Header.Get("Billing-Signature"), body, time.Now()); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := process(body); err != nil {
http.Error(w, "processing error", http.StatusInternalServerError) // will be retried
return
}
w.WriteHeader(http.StatusOK)
}
}
Node.js
import crypto from 'node:crypto';
import express from 'express';
const TOLERANCE_S = 5 * 60;
export function verifyBillingSignature(secret, header, rawBody, nowS = Math.floor(Date.now() / 1000)) {
let t;
const sigs = [];
for (const part of (header ?? '').split(',')) {
const i = part.indexOf('=');
if (i < 0) continue;
const k = part.slice(0, i).trim();
const v = part.slice(i + 1).trim();
if (k === 't') t = v;
if (k === 'v1') sigs.push(v);
}
if (!t || sigs.length === 0) throw new Error('incomplete Billing-Signature header');
const ts = Number(t);
if (!Number.isInteger(ts) || Math.abs(nowS - ts) > TOLERANCE_S) throw new Error('timestamp outside tolerance');
const want = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
const ok = sigs.some((s) => {
const got = Buffer.from(s, 'hex');
return got.length === want.length && crypto.timingSafeEqual(got, want); // constant time
});
if (!ok) throw new Error('invalid signature');
}
const app = express();
// Raw body required: express.json() would rewrite it and the signature would no longer match.
app.post('/hooks/billing', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
try {
verifyBillingSignature(process.env.BILLING_WEBHOOK_SECRET, req.get('Billing-Signature'), req.body);
} catch (e) {
return res.status(400).send(e.message);
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.id before acting (an event may be sent again).
await handle(event);
res.sendStatus(200);
});
What you get
For each chosen event, a signed POST to your address. Answer 2xx as soon as the event is
recorded on your side.
What you can expect
- Retries: without a
2xxanswer, the call is sent again with an increasing delay, for 72 hours at most. - Same
idon every attempt: the same event may arrive several times. Deduplicate onidand make your processing replayable. - No guaranteed order between two close events: rely on the content (the object's
status), not on the arrival order. enabled: falsesuspends deliveries without deleting the webhook.
If it does not work
| What you see | Cause | Fix |
|---|---|---|
sng apply rejects the url | http:// or non-public address | a public https:// address |
sng apply rejects an event | misspelt name | use the list above |
| Signature always invalid | body re-read or re-encoded before verification (express.json(), middleware…), wrong secret, or space/newline around the secret | verify on the raw body; read the secret again with sng secret get |
| "timestamp outside tolerance" | your server clock is off | synchronise the clock (NTP) |
| The same event arrives several times | your address answered too late or with an error | answer 2xx quickly; deduplicate on id |
| No call received | event not in events, enabled: false, or address unreachable from the Internet | check the manifest and the address reachability |
See also Payments troubleshooting.