Webhooks
With webhooks your system learns about things that happen in Walnut as they happen: a member signs up, points change, a coupon is issued or redeemed. You subscribe an HTTPS URL to the events you care about and we send a signed POST request for every event.
These subscriptions are separate from the order webhooks of a webshop, which are configured per shop and documented under Shops (webhooks).
Abilities
Request a token with the abilities below via POST /oauth/token. A token missing the ability gets an HTTP 403. Subscriptions belong to the organization of the token: you only ever see, create or delete subscriptions of your own organization (a foreign subscription answers 404).
- Name
webhooks.get- Type
- ability
- Description
List subscriptions.
- Name
webhooks.store- Type
- ability
- Description
Create and delete subscriptions.
The subscription model
- Name
id- Type
- string
- Description
Unique identifier of the subscription (a UUID).
- Name
organization_id- Type
- string
- Description
Identifier of the organization the subscription belongs to.
- Name
url- Type
- string
- Description
The HTTPS URL we send events to.
- Name
events- Type
- array
- Description
The event types this subscription receives.
- Name
active- Type
- boolean
- Description
Whether deliveries are currently sent.
- Name
created_at- Type
- string
- Description
ISO 8601 timestamp.
- Name
secret- Type
- string
- Description
The signing secret (
whsec_...). Only present in the response ofPOST /webhook-subscriptions. Store it right away: it cannot be retrieved again. To rotate, create a new subscription and delete the old one.
List subscriptions
Returns the subscriptions of your organization. Optional query parameter organization_id selects one organization when your token has access to several.
Request
curl https://api.walletapp.co/webhook-subscriptions \
-H "Authorization: Bearer {token}"
Response
{
"message": "Request successful",
"data": [
{
"id": "6f1d3c52-0b0e-4c53-a8ad-1c4f6a5d9b21",
"organization_id": "5b2f0c3e-1111-4222-8333-444455556666",
"url": "https://partner.example.com/hooks/walnut",
"events": ["member.created", "points.changed"],
"active": true,
"created_at": "2026-10-08T10:00:00+00:00"
}
]
}
Create a subscription
Body
- Name
url- Type
- string
- Description
Must be an
https://URL on a public host. Plainhttp,localhost, private network addresses and URLs with credentials are rejected with422(httpis only accepted in a local development environment).
- Name
events- Type
- array
- Description
One or more of
member.created,points.changed,coupon.issued,coupon.redeemed. Unknown events are rejected with422.
- Name
organization_id- Type
- string
- Description
Required only when your token has access to more than one organization.
The signing secret is generated by us; a secret in the request body is ignored. At most 20 subscriptions per organization.
Request
curl -X POST https://api.walletapp.co/webhook-subscriptions \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{"url":"https://partner.example.com/hooks/walnut","events":["member.created","points.changed"]}'
Response
{
"message": "Request successful",
"data": {
"id": "6f1d3c52-0b0e-4c53-a8ad-1c4f6a5d9b21",
"organization_id": "5b2f0c3e-1111-4222-8333-444455556666",
"url": "https://partner.example.com/hooks/walnut",
"events": ["member.created", "points.changed"],
"active": true,
"created_at": "2026-10-08T10:00:00+00:00",
"secret": "whsec_k3J9x0fQyV2mLq8TnB5aZc7dRw1eHs4uPoXgYiMv"
}
}
Delete a subscription
Stops all future deliveries for the subscription. Deliveries that are already waiting for a retry are dropped. Returns 404 for an unknown id or a subscription of another organization.
Request
curl -X DELETE https://api.walletapp.co/webhook-subscriptions/6f1d3c52-0b0e-4c53-a8ad-1c4f6a5d9b21 \
-H "Authorization: Bearer {token}"
Response
{
"message": "Request successful",
"data": { "id": "6f1d3c52-0b0e-4c53-a8ad-1c4f6a5d9b21", "deleted": true }
}
Consuming webhooks
Every delivery is a POST with a JSON body in this envelope. Check type to see what happened; the event specific fields are in data.
Envelope
{
"id": "0b8f4f0e-6d6e-4f0a-9f43-6c1d1a2b3c4d",
"type": "points.changed",
"created_at": "2026-10-08T10:15:00+00:00",
"organization_id": "5b2f0c3e-1111-4222-8333-444455556666",
"data": { }
}
Headers:
- Name
WLLT-Signature- Type
- string
- Description
Hex encoded HMAC-SHA256 of the raw request body with your subscription secret. See Verifying the signature.
- Name
WLLT-Message-Id- Type
- string
- Description
The event id (same as
idin the body). It is identical for every retry of the same event, so use it to de-duplicate.
- Name
WLLT-Event- Type
- string
- Description
The event type, e.g.
coupon.issued.
- Name
WLLT-Attempt- Type
- integer
- Description
Delivery attempt number, starting at 1.
Payloads contain identifiers only (member UUID, pass UUID, coupon identifier). They never contain names, e-mail addresses or other personal data; use the members and coupons endpoints to look details up.
Respond with any 2xx status within 5 seconds. Do the heavy work asynchronously after acknowledging.
Event types
- Name
member.created- Type
- Description
A member joined a brand of your organization (sign-up, import or API).
data:member_id,brand_id.
- Name
points.changed- Type
- Description
The points of a member on a storecard changed.
data:member_id,storecard_id,pass_id,type(earn,spendorreverse),points(absolute amount),balance(points after the change),reference(the transaction reference, unique per transaction).
- Name
coupon.issued- Type
- Description
A coupon was issued to a member, one event per issued pass.
data:coupon_id,pass_id,member_id,expires_at.
- Name
coupon.redeemed- Type
- Description
A coupon pass was redeemed.
data:coupon_id,pass_id,member_id,redeemed_at.
member.created
{
"id": "0b8f4f0e-6d6e-4f0a-9f43-6c1d1a2b3c4d",
"type": "member.created",
"created_at": "2026-10-08T10:15:00+00:00",
"organization_id": "5b2f0c3e-1111-4222-8333-444455556666",
"data": {
"member_id": "9a1c2d3e-4f50-4a6b-8c7d-0e1f2a3b4c5d",
"brand_id": "3c4d5e6f-7081-4192-a3b4-c5d6e7f80910"
}
}
points.changed
{
"id": "7d1c8e3a-2b4f-4c6d-9e0a-1b2c3d4e5f60",
"type": "points.changed",
"created_at": "2026-10-08T10:16:00+00:00",
"organization_id": "5b2f0c3e-1111-4222-8333-444455556666",
"data": {
"member_id": "9a1c2d3e-4f50-4a6b-8c7d-0e1f2a3b4c5d",
"storecard_id": "STC4F8K2LQ",
"pass_id": "e2f1a0b9-c8d7-4e6f-a5b4-c3d2e1f0a9b8",
"type": "earn",
"points": 25,
"balance": 145,
"reference": "pos-2026-10-08-000123"
}
}
coupon.issued
{
"id": "c4b3a291-8f7e-4d6c-b5a4-39281706f5e4",
"type": "coupon.issued",
"created_at": "2026-10-08T10:17:00+00:00",
"organization_id": "5b2f0c3e-1111-4222-8333-444455556666",
"data": {
"coupon_id": "CPN7H2M9QX",
"pass_id": "e2f1a0b9-c8d7-4e6f-a5b4-c3d2e1f0a9b8",
"member_id": "9a1c2d3e-4f50-4a6b-8c7d-0e1f2a3b4c5d",
"expires_at": "2026-12-31T23:59:59+00:00"
}
}
coupon.redeemed
{
"id": "1f2e3d4c-5b6a-4798-8a9b-0c1d2e3f4a5b",
"type": "coupon.redeemed",
"created_at": "2026-10-08T10:20:00+00:00",
"organization_id": "5b2f0c3e-1111-4222-8333-444455556666",
"data": {
"coupon_id": "CPN7H2M9QX",
"pass_id": "e2f1a0b9-c8d7-4e6f-a5b4-c3d2e1f0a9b8",
"member_id": "9a1c2d3e-4f50-4a6b-8c7d-0e1f2a3b4c5d",
"redeemed_at": "2026-10-08T10:20:00+00:00"
}
}
Verifying the signature
Every request carries a WLLT-Signature header: the hex encoded HMAC-SHA256 of the raw request body (exactly the bytes we sent, do not re-serialize the JSON) using the secret you received when you created the subscription. Compare it in constant time and reject requests that do not match.
Verifying a request
const crypto = require('crypto')
// rawBody must be the unparsed request body (Buffer or string)
const signature = req.headers['wllt-signature']
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const valid =
signature &&
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
Keep the secret safe and never commit it to a repository. If it leaks, create a new subscription and delete the old one.
Retries
A delivery counts as successful when your endpoint answers with a 2xx status within 5 seconds. Redirects are not followed.
- Name
2xx- Type
- Description
Delivered. No further attempts.
- Name
4xx- Type
- Description
Final. Your endpoint rejected the event, so we do not retry (except
408and429, which are retried like a server error).
- Name
5xx, 408, 429, 3xx, timeout, connection error- Type
- Description
Retried. Up to 8 attempts in total, with these delays between attempts: 30 seconds, 1 minute, 10 minutes, 1 hour, 3 hours, 6 hours and 24 hours.
Retries reuse the same WLLT-Message-Id, so your handler must be idempotent: store the ids you processed and ignore repeats. Events are delivered at least once and not necessarily in order; use created_at if ordering matters.
Every attempt, successful or not, is recorded in our delivery log together with the HTTP status code and the error, so a failing endpoint can be diagnosed on our side.
