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.

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 of POST /webhook-subscriptions. Store it right away: it cannot be retrieved again. To rotate, create a new subscription and delete the old one.


GET/webhook-subscriptions

List subscriptions

Returns the subscriptions of your organization. Optional query parameter organization_id selects one organization when your token has access to several.

Request

GET
/webhook-subscriptions
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"
    }
  ]
}

POST/webhook-subscriptions

Create a subscription

Body

  • Name
    url
    Type
    string
    Description

    Must be an https:// URL on a public host. Plain http, localhost, private network addresses and URLs with credentials are rejected with 422 (http is 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 with 422.

  • 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

POST
/webhook-subscriptions
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/webhook-subscriptions/:id

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

DELETE
/webhook-subscriptions/:id
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 id in 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, spend or reverse), 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 408 and 429, 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.