Zupy can deliver a customer’s loyalty card or coupon as an Apple Wallet / Google Wallet
pass. As a partner you only tell Zupy which customer and which platform — Zupy generates
the pass from the merchant’s existing card/coupon design and template.
Generation is reference-only. The integration cannot personalize the pass (no custom fields,
colors, or templates). Zupy builds it from the data and wallet template the merchant already
configured — exactly the same pass the customer would get from the program landing page.
How it works
Both the program landing page and the partner API use the same generator
(PassGenerationService). The only difference is delivery:
| Flow | Endpoint | Delivery |
|---|
| Landing page (in-browser) | downloads the .pkpass directly | streams the file / redirects to Google |
| Partner API | POST /api/v2/wallet/passes/loyalty/ | returns a pass_url you hand to the customer |
The pass_url is the same Apple .pkpass URL / Google Wallet save link the LoyaltyCard carries.
The customer opens it to add the card to their wallet.
Generate a loyalty card pass
POST /api/v2/wallet/passes/loyalty/
Request body:
| Field | Type | Description |
|---|
customer_id | string | Required. Customer KSUID (the LoyaltyUser). |
platform | string | Required. apple or google. |
curl -X POST "https://api.zupy.com/api/v2/wallet/passes/loyalty/" \
-H "X-API-Key: zupy_pk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"customer_id": "2awTHloSJX7kGGprFerOOsvABcd", "platform": "apple"}'
{
"data": {
"id": "2cxSLnqYLZ0nJJsuIhuSSvyFGij",
"pass_url": "https://api.zupy.com/wallet/apple/2cxSLnqYLZ0nJJsuIhuSSvyFGij.pkpass",
"platform": "apple",
"created_at": "2026-05-25T18:00:00Z"
},
"meta": {}
}
Deliver pass_url to the customer (link, button, QR). Opening it adds the card to their wallet.
Generate a coupon pass
POST /api/v2/wallet/passes/coupons/
Same idea, plus the issued coupon to wrap:
| Field | Type | Description |
|---|
customer_id | string | Required. Customer KSUID. |
coupon_id | string | Required. Issued coupon KSUID (a RewardRedemption, see Coupon Lifecycle). |
platform | string | Required. apple or google. |
Check pass status
GET /api/v2/wallet/passes/{pass_id}/status/
Returns whether the pass was added/removed by the customer (useful for engagement metrics).
List a customer’s passes
GET /api/v2/wallet/passes/?customer_id={id}
Before firing a push notification, you usually want to know whether the customer actually added
the pass to their wallet — and on which platform — so you can act intelligently (e.g. show an
“Add to Wallet” prompt instead of pushing into the void, or send an SMS fallback when no wallet
install exists).
GET /api/v2/customers/{customer_id}/wallet-devices/
The response separates Apple Wallet (per-device list, because PassKit registers each iPhone
explicitly) from Google Wallet (flat pass list, because Google syncs across devices
server-side and we have no per-device telemetry).
{
"data": {
"customer_id": "2abc...",
"summary": {
"apple": { "installed": true, "device_count": 1, "pass_count": 1 },
"google": { "installed": false, "pass_count": 0 }
},
"apple_devices": [
{
"device_library_identifier": "5defd6b5d63fd0c3ed16690bbbce9709",
"platform_hint": "ios",
"locale": "pt-BR",
"first_registered_at": "2026-05-26T07:13:25Z",
"last_registered_at": "2026-05-26T07:13:25Z",
"registered_passes": [
{
"serial_number": "40d96d49-ad9e-4b65-9675-6333d39ffcfb",
"pass_type_identifier": "pass.com.zupy.clube.public",
"pass_type": "coupon",
"reward_name": "Vale Sobremesa",
"last_push_sent": "2026-05-26T07:42:07Z"
}
]
}
],
"google_passes": []
},
"meta": {}
}
summary.apple.installed + summary.google.installed is the fastest signal: at least one
side true means a POST /api/v2/wallet/notifications/ call will reach the customer.
Send a push notification to the wallet pass
Push an update + message into the customer’s wallet pass. The notification fans out
automatically to all platforms the customer has the pass installed on — Apple (APNs silent
push → iPhone re-downloads the pkpass with the new back-field message) and Google (server-side
PATCH on the Wallet object → surfaces in the user’s Google Wallet feed).
POST /api/v2/wallet/notifications/
Request body
| Field | Type | Required | Description |
|---|
customer_id | string (KSUID) | Yes | The LoyaltyUser.id (same one you use everywhere else). |
type | string enum | Yes | One of points_update, coupon_available, or program_update. Controls how the message is categorized in the customer’s wallet feed; the actual text is always your message. |
message | string (≤500 chars) | Yes | The text the customer reads in the wallet pass back-field. Plain text only; emojis fine. |
Response — 202 Accepted
| Field | Type | Description |
|---|
notification_id | string (KSUID) | Async dispatch ID. Track it in logs; there’s no GET endpoint to poll status (the fanout is best-effort). |
status | string | Always "queued" on 202 — the Celery worker takes it from here. |
curl -X POST "https://api.zupy.com/api/v2/wallet/notifications/" \
-H "X-API-Key: zupy_pk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "2awTHloSJX7kGGprFerOOsvABcd",
"type": "points_update",
"message": "Você ganhou 1500 pontos! Saldo: 1520"
}'
response = requests.post(
f"{BASE_URL}/wallet/notifications/",
json={
"customer_id": customer_id,
"type": "points_update",
"message": f"Você ganhou {amount} pontos! Saldo: {new_balance}",
},
headers=HEADERS,
)
notification_id = response.json()["data"]["notification_id"]
const response = await fetch(`${BASE_URL}/wallet/notifications/`, {
method: "POST",
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
customer_id: customerId,
type: "points_update",
message: `Você ganhou ${amount} pontos! Saldo: ${newBalance}`,
}),
});
const { data } = await response.json();
console.log(`Queued: ${data.notification_id}`);
When to use each type
type | Use when | Example message |
|---|
points_update | Points balance changed (earned, spent, manual adjust). Paired with the relevant operation (/points/add/, redeem, etc.) | "Você ganhou 1500 pontos! Saldo: 1520" |
coupon_available | New coupon was just issued for the customer — let them open the wallet pass and see it | "Seu cupom Sobremesa Grátis está disponível!" |
program_update | Generic program-wide news (campaign, tier change, expiry warning) that isn’t tied to a specific transaction | "Nova campanha — descontos esta semana" |
The choice doesn’t change delivery behavior — it’s metadata that helps the customer’s wallet UI group similar notifications. Pick the one that best matches the trigger.
Notification errors
| Status | Type | When |
|---|
| 202 | — | Queued successfully. The async fanout decides per-platform delivery. |
| 404 | not-found | customer_id doesn’t exist in your company |
| 422 | validation-error | Invalid type (not in the enum), missing/empty message, or message > 500 chars |
This is a best-effort fire-and-forget dispatch. A 202 means we accepted the request, not that the push reached the customer’s device. Reasons it may silently drop later:
- The customer has no wallet pass installed (verify with
GET /customers/{id}/wallet-devices/ first if you need a guarantee).
- The customer uninstalled the pass after generation (Apple returns
BadDeviceToken → registration is purged).
- Rate-limit guard: max 10 notifications per customer per hour (Redis-tracked, returns 202 but logs the throttle).
What the customer actually sees
- iPhone (Apple Wallet): silent APNs push (no banner) → Wallet quietly re-downloads the pkpass → the back-field
"Última Mensagem" updates to your message. The pass icon may pulse briefly on the lock screen depending on iOS settings.
- Android (Google Wallet): server-side
objects.patch → notification appears in the Google Wallet feed with the merchant’s class branding. May trigger a system notification on the device depending on the user’s Wallet notification settings.
Typical latency: 5-15 seconds to APNs, 30-60 seconds to Google. If the iPhone shows the old message after a push, the customer can pull-to-refresh on the pass — that forces a non-conditional GET /v1/passes/.../ and bypasses any stale cache.
Wallet endpoints require a read-write API key (they create passes). Read-only keys get
403 on the create endpoints.