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.

Status: design spec — pre-release

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 unchanged from a consumer-issued claim; only the source differs.

Reward type is unchanged. This API changes only how a claim is issued — not what it is. No new reward types, no new billing. One Purchase Order per campaign, as always.

The trust boundary

Responsibility is split along one clean seam. You own qualification; Nibble owns entitlement, dedup and issuance — because Nibble owns the scarce thing (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.

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 to accept grants. Promo-URL campaigns 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. Tokens are Stripe-style and shown once at creation — Nibble stores only a SHA-256 hash.

Authorization header
Authorization: Bearer nib_live_<32+ random base62>
# 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.
  • Transport. HTTPS only. Optional per-key source-IP pinning.

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
  "displayName": "Sam",                    # optional — shown on the redemption screen
  "eligibilityRef": "receipt_20260602_4471", # the qualifying-event id; REQUIRED when perUserLimit > 1
  "venueId": "ven_…"                       # optional — pre-bind to a venue
}

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.
displayNamenoShown on the redemption screen.
venueIdnoPre-binds the claim to a specific venue.

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
}

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 curl above is the contract; this is the call as it lives in your own qualification path. No SDK needed — it’s 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 poke at it before writing code? Import the Postman collection + environment — the collection runs the whole contract (grant, replay, cap, status reads, error codes) with assertions; paste your API key and campaign id and hit Run.

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

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. Conflating them is the classic bug — dedup on the person and a perUserLimit = 3 campaign can only ever issue one reward; dedup on the event and retries double-issue. Nibble does both.

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 (default): Nibble returns the URL; you display or send it. Cleanest for in-app embedding.
  • Nibble-emailed (optional add-on): Nibble also emails the reward to email using its templates.

Security & audit

  • Single issuer. Only Nibble mints redeemable claims — you never hold reward inventory.
  • Constant-time auth. Tokens are looked up by prefix and verified against a stored hash in constant time; revoked keys are rejected.
  • Rate limiting. Per-key token bucket; 429 with a Retry-After header when exceeded.
  • 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.

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.

{
  "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" }
  ]
}
GET/api/v1/campaigns/{id}

Campaign meta. Branch on acceptingGrants — it is true only while the campaign is live and the funded pool covers at least one more reward, so you can stop granting before hitting funds_depleted. rewardsRemaining is a count, not a budget figure.

{
  "id": "cmp_summer",
  "name": "Summer Free Pour",
  "rewardLabel": "Heineken 0.0",
  "status": "live",
  "acceptingGrants": true,
  "perUserLimit": 2,
  "startsAt": "2026-06-01T00:00:00.000Z",
  "endsAt": "2026-07-28T00:00:00.000Z",
  "rewardsRemaining": 218
}
Request API access Back to developer overview