Ponch records an event whenever something happens to a card. You can receive events as webhooks the moment they happen, or fetch them from the events feed. Both give you the same payload for the same event.
| Event | Fires when | id is |
Webhook | Feed |
|---|---|---|---|---|
customer.enrolled |
A customer adds a new card. Re-downloading an existing card doesn't count | the card's id | ✅ | ✅ |
stamp.added |
A card is stamped, or its count is set | the stamp's id | ✅ | ✅ |
reward.earned |
A card reaches its target. One event per reward | the reward's id | ✅ | ✅ |
reward.redeemed |
A reward is redeemed. One event per reward | the reward's id | ✅ | ✅ |
card.voided |
A merchant deactivates a card | <card id>:<number> |
✅ | ✅ |
card.unvoided |
A deactivated card is restored | <card id>:<number> |
✅ | ❌ |
card.unvoided is webhook-only.
Every event payload has these top-level keys:
| Key | |
|---|---|
id |
What happened. Use this to deduplicate. It's the same on every delivery of the same event, including retries and replays |
occurred_at |
When it happened, ISO-8601. The feed is sorted by this |
event |
The event type, e.g. stamp.added |
event_id |
On a webhook, identifies this event's delivery to this webhook: retries and replays repeat it, but two webhooks get different values. On the feed, the same as id. Deduplicate on id, not on this |
company |
{id, name, slug} |
store |
{id, name, slug}: for stamp and reward events, the store where it happened when known; otherwise the template's store. null for a company-wide template with no store recorded |
template |
{id, slug, name} |
customer |
{id, email, phone, display_name}. Empty strings for details the customer didn't give |
card |
The card's state, below |
"card": {
"id": "2b4d6f8a-0c2e-4a6c-8e0a-2c4e6a8c0e4d",
"created_at": "2026-07-29T13:53:53.421230+00:00",
"barcode_value": "SC-1540CD2A802A",
"current_stamps": 0,
"total_stamps_required": 8,
"total_stamps_earned": 8,
"completions": 1,
"pending_rewards": 1,
"status": "active"
}
The fields mean the same as on the card object.
card.voided and card.unvoided need a composite id. One card can be
deactivated, restored and deactivated again, and with a plain card id the
second deactivation would look like a duplicate of the first. Treat the id as
an opaque string; don't parse it.
occurred_at is when the event itself happened. For reward.earned that's
when the reward was earned; for reward.redeemed on the same reward it's when
it was redeemed.
customer.enrolledOnly the shared fields. Here id equals card.id.
{
"id": "2b4d6f8a-0c2e-4a6c-8e0a-2c4e6a8c0e4d",
"event": "customer.enrolled",
"event_id": "71c3e5a7-9b1d-4f3a-8c5e-7a9b1d3f5c6e",
"occurred_at": "2026-07-29T13:53:53.421230+00:00",
"company": {"id": "3f1c2a9e-5b7d-4c11-9a2e-6d0b8f4e7a21", "name": "Luna Coffee", "slug": "luna-coffee"},
"store": {"id": "8a4e6c2b-1d3f-4e5a-b7c9-0f2e4d6a8b13", "name": "Downtown", "slug": "downtown"},
"template": {"id": "c7d9e1f3-2a4b-4c6d-8e0f-1a3b5c7d9e2f", "slug": "coffee-card", "name": "Coffee Card"},
"customer": {
"id": "5e7f9a1b-3c5d-4e7f-9a1b-3c5d7e9f1a2b",
"email": "ana@example.com",
"phone": "+15551234567",
"display_name": "Ana"
},
"card": {
"id": "2b4d6f8a-0c2e-4a6c-8e0a-2c4e6a8c0e4d",
"created_at": "2026-07-29T13:53:53.421230+00:00",
"barcode_value": "SC-1540CD2A802A",
"current_stamps": 0,
"total_stamps_required": 8,
"total_stamps_earned": 0,
"completions": 0,
"pending_rewards": 0,
"status": "active"
}
}
stamp.addedAdds stamp:
"stamp": {
"id": "9c1e3a5b-7d9f-4b1d-a3f5-7b9d1f3a5c7e",
"stamps_added": 1,
"stamps_before": 7,
"stamps_after": 0,
"source": "api",
"reward_earned": true,
"rewards_earned": 1,
"created_at": "2026-08-11T13:27:59.253264+00:00"
}
stamps_added can be negative when a set-count
lowered the card. source is then adjust.stamps_after wraps on a repeating card, so in the example the 8th stamp
takes it to 0. For a counter that only goes up, use card.total_stamps_earned.source is scan, manual, api, bonus or adjust. More values may be
added.rewards_earned can be more than 1 for a bulk stamp. A matching number of
reward.earned events fire alongside it.reward.earnedAdds reward:
"reward": {
"id": "e4a6c8e0-2b4d-4f6a-8c0e-2a4c6e8a0c3b",
"completion_number": 1,
"status": "pending",
"description": "Free coffee",
"earned_at": "2026-08-11T13:27:59.253264+00:00",
"redeemed_at": null,
"stamp_event_id": "9c1e3a5b-7d9f-4b1d-a3f5-7b9d1f3a5c7e"
}
stamp_event_id is the stamp.id of the stamp that earned it. It can be null
on some older rewards.
completion_number is 1 for the card's first reward, 2 for its second, and so
on. description is the merchant's reward text, as written.
reward.redeemedAdds reward, as above, with status: "redeemed", redeemed_at set, and one
more key:
"redeemed_by": {"id": "0d2f4b6d-8f0b-4d2f-a4b6-d8f0b2d4f6a9", "name": "leo"}
redeemed_by is the staff member who redeemed it (name is their username), or
null when the reward was redeemed through the API.
card.voided and card.unvoidedAdd reason, the merchant's note on why (may be empty):
"reason": "Card reported lost"
card.status is voided on card.voided. On card.unvoided it's back to
active, or completed for a one-time card that still has a reward waiting.
Rewards that were pending when the card was deactivated are pending again, so
check card.pending_rewards.
The same event can reach you more than once: a retry after a timeout, a manual
replay, or the feed returning an item you've already processed. Store the
id of each event you handle and skip any you've seen.
commit 5e140ef5493db7e98830f4b7ef4d620de5419518 Author: Peter Sanchez <peter@netlandish.com> Date: 2026-10-20T06:45:49-06:00 add proper title and descriptions to each page