Subscriptions and dunning
Recurring charges against a wallet, with dunning retries and a billing period that can be charged at most once, structurally.
A subscription charges a wallet on a schedule. The design centre is worth knowing before you write any code: a billing period can be charged at most once, structurally, no matter how many retries, replicas, or crashes intervene. You do not need to guard against double billing yourself.
Create one
curl -sS -X POST "$WALLETD_API/v1/subscriptions" \
-H "Authorization: Bearer $WALLETD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sub-user-42-foodpass" \
-d '{
"user_id": "'$USER_ID'",
"merchant_id": "'$MERCHANT_ID'",
"plan_name": "FoodPass",
"amount": 999,
"interval": "monthly"
}'{
"id": "019fee10-5f6a-7b7c-8d8e-9f0a1b2c3d4e",
"user_id": "019f...",
"merchant_id": "019f...",
"plan_name": "FoodPass",
"amount": 999,
"interval": "monthly",
"status": "active",
"next_charge_at": "2026-08-11T05:35:00Z",
"retry_count": 0,
"created_at": "2026-08-11T05:35:00.118Z"
}interval is weekly or monthly. Pass start_at to delay the first charge; omit it and the first charge happens on the next sweep, which is how you make a trial end.
Charges happen on a background sweep, so status is active before any money has moved. Watch subscription.charged to know a period was actually paid.
func (c *Client) Subscribe(ctx context.Context, userID, merchantID, plan string, amount int64, interval string) (*Subscription, error) {
var sub Subscription
body := map[string]any{
"user_id": userID,
"merchant_id": merchantID,
"plan_name": plan,
"amount": amount,
"interval": interval,
}
// One subscription per user per plan: the key says exactly that.
key := fmt.Sprintf("sub-%s-%s", userID, plan)
err := c.Do(ctx, http.MethodPost, "/v1/subscriptions", body, &sub, key)
return &sub, err
}What happens when the wallet is empty
Renewal day arrives and the balance is short. The subscription enters dunning rather than failing outright.
Three retries at +1, +2, +2 days. Each transition emits a webhook, so you can email the user at exactly the right moment:
| Event | What to tell the user |
|---|---|
subscription.retry_scheduled | "We could not charge your wallet. We will try again on the 14th." |
subscription.paused | "Your FoodPass is paused. Top up and resume to restart it." |
subscription.charged | Receipt |
Pause, resume, cancel
curl -sS -X POST "$WALLETD_API/v1/subscriptions/$SUB_ID/pause" \
-H "Authorization: Bearer $WALLETD_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: pause-$SUB_ID-1" -d '{}'
curl -sS -X POST "$WALLETD_API/v1/subscriptions/$SUB_ID/resume" \
-H "Authorization: Bearer $WALLETD_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: resume-$SUB_ID-1" -d '{}'
curl -sS -X POST "$WALLETD_API/v1/subscriptions/$SUB_ID/cancel" \
-H "Authorization: Bearer $WALLETD_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: cancel-$SUB_ID-1" -d '{}'Resume does not bill for missed periods. The billing period re-anchors at the moment of resume, so a subscription paused for three months does not charge three months on the way back. Users hate catch-up charges and they generate chargebacks; if you need to bill for a gap, do it as an explicit payment the user agreed to.
Cancel is terminal. To offer "cancel at period end", cancel when you receive the last subscription.charged for the period you sold.
Statuses
| Status | Meaning |
|---|---|
active | Charging on schedule |
past_due | A charge failed; retries are scheduled. retry_count says which attempt is next |
paused | Dunning gave up, or someone paused it. Nothing is charged |
canceled | Terminal |
Listing
curl -sS "$WALLETD_API/v1/subscriptions?limit=50" -H "Authorization: Bearer $WALLETD_API_KEY"
curl -sS "$WALLETD_API/v1/subscriptions/$SUB_ID" -H "Authorization: Bearer $WALLETD_API_KEY"Why you cannot be double-charged
The idempotency key for a charge is derived internally from the subscription id and the billing period anchor, and the anchor only moves on a successful charge. Every retry of a failed month computes the same key. Two servers sweeping the same subscription at the same moment compute the same key. A crash after charging but before the bookkeeping replays the stored result instead of charging again.
"At most one charge per period" is not careful coordination. It is what the idempotency layer does to anyone who asks twice with the same key. See Idempotency.
Next
- Payments and refunds, which is what a charge actually is.
- Webhooks for the dunning events.