Skip to main content

Payment webhooks

Beta

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 accepts POST.

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​

billing/webhook.yml
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​

EventWhen
payment.succeededa cart was paid
payment.faileda payment on the payment page failed
charge.succeededa pay-as-you-go charge succeeded
charge.action_requireda charge requires strong authentication from your customer (action_url)
charge.faileda charge was declined
subscription.createda subscription started (first period paid)
subscription.reneweda period was charged
subscription.past_duea period failed, retries in progress
subscription.unpaidretries exhausted, subscription unpaid
subscription.canceledsubscription cancelled
invoice.finalizedan invoice or a credit note was issued
refund.succeededa refund was confirmed
refund.faileda 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 2xx answer, the call is sent again with an increasing delay, for 72 hours at most.
  • Same id on every attempt: the same event may arrive several times. Deduplicate on id and 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: false suspends deliveries without deleting the webhook.

If it does not work​

What you seeCauseFix
sng apply rejects the urlhttp:// or non-public addressa public https:// address
sng apply rejects an eventmisspelt nameuse the list above
Signature always invalidbody re-read or re-encoded before verification (express.json(), middleware…), wrong secret, or space/newline around the secretverify on the raw body; read the secret again with sng secret get
"timestamp outside tolerance"your server clock is offsynchronise the clock (NTP)
The same event arrives several timesyour address answered too late or with an erroranswer 2xx quickly; deduplicate on id
No call receivedevent not in events, enabled: false, or address unreachable from the Internetcheck the manifest and the address reachability

See also Payments troubleshooting.

See also​