Overview
The Partner Grant API lets you gate a Nibble reward behind logic that lives in your own systems — “registered in the app”, “uploaded a valid receipt” — while Nibble remains the single issuer of the redeemable reward.
You call one endpoint to say “this person qualified — issue their reward”. Nibble checks the per-person cap, dedups, mints the claim, and returns a hosted redemption URL. Everything downstream — venue redemption, fraud control, PO settlement, reporting — is handled by Nibble.
The Nibble Partner Grant API allows you to determine how a claim is issued — not what it is. The reward is set in the Campaign Manager. With the API, you decide when to issue a reward, and Nibble takes care of the redemption.
Try it in Postman
Run the whole contract before writing any code: a grant, both status reads, and every documented error — each with assertions, so the Runner reports pass or fail per rule.
Partner Grant API collection
One file, no environment to import. Paste your API key and campaign id into its Variables tab, then hit Run.
Scope
Responsibility is split along one clean seam. You own qualification; Nibble owns entitlement, dedup and issuance — because Nibble owns the redemption (the redeemable claim and the PO-funded payout pool).
| Responsibility | Owner | Where |
|---|---|---|
| Qualification — “did they register / upload a valid receipt?” | You | Your app / backend |
| Entitlement + dedup + issuance — “are they under the cap, and what reward?” | Nibble | This API |
Nibble never validates receipts or app registrations, and never receives receipt contents. It stores only an opaque reference for audit. With the Nibble Partner Grant API, you decide when to issue a reward.
Base URL & versioning
All requests are JSON over HTTPS. The API is versioned in the path; v1 is current.
A campaign must have its entry point set to api in the Campaign Manager to accept grants. Campaigns which are not set up as API-eligible reject API calls with 403 not_api_campaign.
Authentication
Authenticate with a bearer token you generate in the Nibble Campaign Manager — under Settings → API keys, available once a campaign uses the API handoff. Each key is an opaque secret behind an environment prefix — nib_live_ or nib_test_ — so you can tell at a glance which one you are holding. It is shown once at creation and cannot be retrieved afterwards — by you or by Nibble — so store it somewhere safe. Lost a key? Issue a replacement and revoke the old one.
Authorization: Bearer nib_live_<your key>
# test keys use the nib_test_ prefix
- Scope. Prefer one key per campaign. A key scoped to another campaign or org is rejected with
403 campaign_not_authorized. - Environments.
liveandtestkeys are distinct; test keys never touch real settlement. - Rotation. Revoke a key and issue a replacement from the Campaign Manager; both can be live briefly for zero-downtime rotation.
- Rate limits. Per key: at least 10 requests per second sustained, and short bursts above that. Over the limit returns
429with aRetry-Afterheader in seconds — wait it out and retry. A retry cannot double-issue: the same person andeligibilityRefreplays the original claim. - Transport. HTTPS only.
Keep secrets server-side. This is a server-to-server API. Never embed a nib_live_ token in a mobile or web client.
Create a grant
Call this once a person has qualified in your system. The response is a hosted redemption URL you display in your app or send to the consumer.
POST /api/v1/campaigns/cmp_summer_free_pour/grants
Authorization: Bearer nib_live_xxxxxxxxxxxxxxxxxxxx
Idempotency-Key: 5f3c… # optional; see Cap & idempotency
Content-Type: application/json
{
"externalUserId": "hk_8f2a91", # REQUIRED — the per-person cap counts on this
"email": "[email protected]", # REQUIRED — stored + used if Nibble delivers
"eligibilityRef": "receipt_20260602_4471", # the qualifying-event id; REQUIRED when perUserLimit > 1
"venueId": "ven_…", # optional — pre-bind to a venue
"delivery": "url" # optional — "url" (default) | "email"
}
Request body
| Field | Required | Purpose |
|---|---|---|
externalUserId | yes | The person key. The perUserLimit cap is counted per (campaign, externalUserId). Must be stable for the same person across calls. |
email | yes | Stored on the claim and used if Nibble delivers the reward. Run through the campaign’s email-validation policy (plus-aliases and disposable domains rejected). |
eligibilityRef | conditional | Identifies one qualifying event (e.g. a receipt id). Required when perUserLimit > 1; optional when it is 1. Makes the call safe to retry. |
venueId | no | Pre-binds the claim to a specific venue. |
delivery | no | "url" (default): the response carries the redemptionUrl and you deliver it. "email": Nibble also emails the reward to email, and the response reports whether the send succeeded. Any other value is rejected with 400 invalid_request. |
Why externalUserId is required. The genuinely scarce thing — one app account, one verified receipt — lives in your system, so you must own the anti-abuse identity. Email is weak (aliases, throwaways, sharing) and is kept only as a delivery address and a fallback key.
Responses
201 Created — fresh grant
{
"claimId": "rid_a1b2c3d4e5f6",
"redemptionUrl": "https://claim.nibble-app.io/c/cmp_summer_free_pour?claim=rid_a1b2c3d4e5f6",
"state": "valid",
"expiresAt": "2026-06-02T12:10:00Z",
"alreadyGranted": false
}
201 Created — when Nibble sends the email
With "delivery": "email" the same body carries one extra object. A failed send never fails the grant — you still get the redemptionUrl and can deliver it yourself.
{
… same fields as above …
"delivery": { "method": "email", "sent": true, "detail": "sent" }
}
200 OK — idempotent replay
The same (person, grant_ref) already issued a claim. You get the same body as the 201 with "alreadyGranted": true — safe to re-call and re-show the reward.
In your backend
The same call as it lives in your own qualification path. There is no SDK — it is one HTTPS request.
// After your own logic decides this person qualified:
const res = await fetch(
`https://api.nibble-app.io/api/v1/campaigns/${CAMPAIGN_ID}/grants`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NIBBLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
externalUserId: user.id, // your stable user id
email: user.email,
eligibilityRef: receipt.id, // one per qualifying event — makes retries safe
}),
},
);
const grant = await res.json();
if (res.ok) {
// 201 fresh or 200 replay — same shape either way.
showInApp(grant.redemptionUrl);
} else if (grant.error === "limit_reached") {
// Person already has their reward(s); grant.claimId points at the existing one.
} else {
// Branch on grant.error — see the error reference below.
}
import os, requests
res = requests.post(
f"https://api.nibble-app.io/api/v1/campaigns/{CAMPAIGN_ID}/grants",
headers={"Authorization": f"Bearer {os.environ['NIBBLE_API_KEY']}"},
json={
"externalUserId": user.id,
"email": user.email,
"eligibilityRef": receipt.id,
},
timeout=10,
)
grant = res.json()
if res.ok:
show_in_app(grant["redemptionUrl"]) # 201 fresh or 200 replay
elif grant["error"] == "limit_reached":
... # person already has their reward(s)
Prefer to run it first? The Postman collection exercises every call on this page, with assertions.
Error reference
Every error shares the shape { "error": "<code>", "message": "<text>" }, sometimes with extra fields (state, claimId, reason, retryAfter). Branch on the code, not the prose — messages are English (you map them to consumer copy in your own language).
Every response also carries a requestId, repeated in the X-Request-Id header. Log it. Quoting one lets us find the exact call in seconds instead of asking you to reproduce it.
| Status | Code | When it fires |
|---|---|---|
| 400 | invalid_request | Malformed JSON or wrong types. |
| 400 | missing_external_user_id | No externalUserId. |
| 400 | missing_email | No email. |
| 400 | grant_ref_required | perUserLimit > 1 and no eligibilityRef/Idempotency-Key — a retry can’t be told from a new event. |
| 401 | unauthorized | Missing, garbled, unknown or revoked bearer token. |
| 403 | campaign_not_authorized | Key is scoped to a different campaign or org. |
| 403 | not_api_campaign | Campaign’s entry point isn’t api. |
| 404 | campaign_not_found | Campaign row missing. In practice an unknown campaignId returns campaign_not_authorized — keys are campaign-scoped, and we don’t reveal whether an id you’re not scoped to exists. |
| 409 | campaign_not_live | Status is draft / scheduled / awaiting-po. |
| 409 | campaign_paused | Campaign is paused. |
| 409 | campaign_ended | Campaign has ended. |
| 409 | email_rejected | Fails the email policy (reason: plus-alias | blocked-domain | format). |
| 409 | limit_reached | Person is at perUserLimit. Carries claimId + state. |
| 409 | funds_depleted | Remaining funded pool can’t cover one more reward. |
| 429 | rate_limited | Per-key rate limit exceeded. Carries retryAfter + Retry-After header. |
| 500 | internal_error | Unexpected server error — safe to retry. |
{
"error": "limit_reached",
"message": "This person has already received the maximum number of rewards for this campaign.",
"claimId": "rid_a1b2c3d4e5f6",
"state": "used"
}
Cap & idempotency
The design keeps two counters separate. The cap counts people, so a perUserLimit = 3 campaign can issue three rewards; idempotency counts events, so a retry of one event never issues a second. Nibble applies both on every call.
| Question | Key | |
|---|---|---|
| Cap | How many rewards may this person get? | person_key = externalUserId |
| Idempotency | Is this the same qualifying event we already issued for? | grant_ref = eligibilityRef ?? Idempotency-Key ?? 'single' |
perUserLimit = 1(~90% of campaigns):eligibilityRefis optional. One claim per person; a second call replays or hitslimit_reached.perUserLimit > 1: send a distincteligibilityRefper qualifying event — it’s the only way to tell “a second receipt → a second reward” from “the server retried”. Missing it →400 grant_ref_required.
Race-safe by construction. The cap is enforced transactionally with a per-person advisory lock, backed by a Postgres partial unique index on (campaign, person_key, grant_ref). Two simultaneous requests for the same person can never both slip past the cap, and two identical events can never both insert.
Redemption handoff
The API returns a hosted redemptionUrl — not raw reward data. You embed or open that URL in your app, or email it to the consumer. The page renders Nibble’s branded confirmation and QR, and drives venue redemption + PO accounting, so your codebase never forks reward logic.
- Return-only —
"delivery": "url", the default: Nibble returns the URL; you display or send it. Cleanest for in-app embedding. - Nibble-emailed —
"delivery": "email": Nibble also emails the reward toemailusing its templates, and still returns the URL.
Security & audit
- Single issuer. Only Nibble mints redeemable claims — you never hold reward inventory.
- Keys cannot be read back. A key is shown once at creation and is never recoverable from Nibble afterwards. Revocation takes effect on the next call.
- Rate limiting. Every key is throttled independently, so one integration's burst cannot slow another's.
- Auditable. Every grant and rejection writes an org-scoped audit row keyed to the partner key, storing a hash of the person key plus the opaque
eligibilityRef— never receipt contents. - No PII overreach. Nibble stores email (as for consumer claims) and opaque refs; it never receives or stores receipt images, or other PII data.
Status endpoints
Companion read endpoints let you check state without issuing. Same bearer auth and rate limits as the grants endpoint.
A person’s standing on the campaign — no claim is issued. Zero grants is a normal 200 with granted: 0. Use it to re-deliver a lost link (redemptionUrl) or to skip the grant call for someone already at the limit.
{
"externalUserId": "hk_8f2a91",
"perUserLimit": 2,
"granted": 2,
"remaining": 0,
"atLimit": true,
"claims": [
{ "claimId": "rid_a1b2c3d4e5f6", "state": "used", "grantRef": "receipt_20260602_4471",
"redemptionUrl": "https://claim.nibble-app.io/c/cmp_summer/?claim=rid_a1b2c3d4e5f6",
"createdAt": "2026-06-02T18:11:04.000Z", "usedAt": "2026-06-02T19:40:12.000Z",
"expiresAt": "2026-07-28T00:00:00.000Z" }
]
}
| Field | Meaning |
|---|---|
granted / remaining | Claims this person holds on the campaign, and how many more the cap allows. |
atLimit | The one field to branch on: true means a further grant returns 409 limit_reached. |
claims[].state | valid — issued, not yet redeemed. used — redeemed at a venue. expired — the campaign ended first. |
claims[].grantRef | The eligibilityRef you sent when this claim was issued, or single on a one-per-person campaign that sent none. |
claims[].redemptionUrl | The consumer link. Re-send it to replace one a consumer lost. |
claims[].usedAt | When the venue redeemed it, or null while unredeemed. |
claims[].expiresAt | Campaign end — every claim on a campaign expires together. |
The campaign's current state and what the consumer wins. Poll it to stop granting before you hit funds_depleted, and to render the reward in your own UI.
{
"id": "cmp_summer",
"name": "Summer Rewards",
"rewardLabel": "Heineken 0.0",
"rewardImageUrl": "https://…/reward-images/cmp_summer/reward.jpg",
"status": "live",
"acceptingGrants": true,
"perUserLimit": 2,
"startsAt": "2026-06-01T00:00:00.000Z",
"endsAt": "2026-07-28T00:00:00.000Z",
"rewardsRemaining": 218
}
| Field | Meaning |
|---|---|
status | The campaign's effective status — live, paused, ended, scheduled. A brand Nibble has put on hold reads as paused here. |
acceptingGrants | The one field to branch on: true only while the campaign is live, uses the API entry point, and the funded pool covers at least one more reward. |
perUserLimit | Rewards one person may earn. Above 1, every grant must carry an eligibilityRef. |
rewardsRemaining | How many more rewards the funded pool covers. A count, never a budget figure. |
rewardLabel / rewardImageUrl | What the consumer wins, for rendering in your own UI. The image is a public URL, or null when the brand uploaded none. |
startsAt / endsAt | Campaign window. endsAt is also every claim's expiry. |