API reference · v1

Partner Grant API

Issue Nibble rewards from your own product. You decide who qualifies; Nibble is the single, idempotent issuer that guarantees one reward per person and pays the venue. Server-to-server, JSON over HTTPS.

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.

Download collection

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).

ResponsibilityOwnerWhere
Qualification — “did they register / upload a valid receipt?”YouYour app / backend
Entitlement + dedup + issuance — “are they under the cap, and what reward?”NibbleThis 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.

POSThttps://api.nibble-app.io/api/v1/campaigns/{campaignId}/grants

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 header
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. live and test keys 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 429 with a Retry-After header in seconds — wait it out and retry. A retry cannot double-issue: the same person and eligibilityRef replays 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

POST/api/v1/campaigns/{campaignId}/grants

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.

request
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

FieldRequiredPurpose
externalUserIdyesThe person key. The perUserLimit cap is counted per (campaign, externalUserId). Must be stable for the same person across calls.
emailyesStored on the claim and used if Nibble delivers the reward. Run through the campaign’s email-validation policy (plus-aliases and disposable domains rejected).
eligibilityRefconditionalIdentifies one qualifying event (e.g. a receipt id). Required when perUserLimit > 1; optional when it is 1. Makes the call safe to retry.
venueIdnoPre-binds the claim to a specific venue.
deliveryno"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

201 Created
{
  "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.

201 Created · delivery: "email"
{
  … 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.

Node 18+ · issueReward.ts
// 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.
}
Python 3 · issue_reward.py
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.

StatusCodeWhen it fires
400invalid_requestMalformed JSON or wrong types.
400missing_external_user_idNo externalUserId.
400missing_emailNo email.
400grant_ref_requiredperUserLimit > 1 and no eligibilityRef/Idempotency-Key — a retry can’t be told from a new event.
401unauthorizedMissing, garbled, unknown or revoked bearer token.
403campaign_not_authorizedKey is scoped to a different campaign or org.
403not_api_campaignCampaign’s entry point isn’t api.
404campaign_not_foundCampaign 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.
409campaign_not_liveStatus is draft / scheduled / awaiting-po.
409campaign_pausedCampaign is paused.
409campaign_endedCampaign has ended.
409email_rejectedFails the email policy (reason: plus-alias | blocked-domain | format).
409limit_reachedPerson is at perUserLimit. Carries claimId + state.
409funds_depletedRemaining funded pool can’t cover one more reward.
429rate_limitedPer-key rate limit exceeded. Carries retryAfter + Retry-After header.
500internal_errorUnexpected server error — safe to retry.
409 limit_reached
{
  "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.

QuestionKey
CapHow many rewards may this person get?person_key = externalUserId
IdempotencyIs this the same qualifying event we already issued for?grant_ref = eligibilityRef ?? Idempotency-Key ?? 'single'
  • perUserLimit = 1 (~90% of campaigns): eligibilityRef is optional. One claim per person; a second call replays or hits limit_reached.
  • perUserLimit > 1: send a distinct eligibilityRef per 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 to email using 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.

GET/api/v1/campaigns/{id}/grants/{externalUserId}

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.

200 OK · person status
{
  "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" }
  ]
}
FieldMeaning
granted / remainingClaims this person holds on the campaign, and how many more the cap allows.
atLimitThe one field to branch on: true means a further grant returns 409 limit_reached.
claims[].statevalid — issued, not yet redeemed. used — redeemed at a venue. expired — the campaign ended first.
claims[].grantRefThe eligibilityRef you sent when this claim was issued, or single on a one-per-person campaign that sent none.
claims[].redemptionUrlThe consumer link. Re-send it to replace one a consumer lost.
claims[].usedAtWhen the venue redeemed it, or null while unredeemed.
claims[].expiresAtCampaign end — every claim on a campaign expires together.
GET/api/v1/campaigns/{id}

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.

200 OK · campaign meta
{
  "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
}
FieldMeaning
statusThe campaign's effective status — live, paused, ended, scheduled. A brand Nibble has put on hold reads as paused here.
acceptingGrantsThe 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.
perUserLimitRewards one person may earn. Above 1, every grant must carry an eligibilityRef.
rewardsRemainingHow many more rewards the funded pool covers. A count, never a budget figure.
rewardLabel / rewardImageUrlWhat the consumer wins, for rendering in your own UI. The image is a public URL, or null when the brand uploaded none.
startsAt / endsAtCampaign window. endsAt is also every claim's expiry.
Request API access Back to developer overview