Linger API

Read your brand's offers, codes and followers, and report redemptions from any shop or till. Every call is scoped to the brand that owns the API key, and followers are only people who consented to that brand.

Base URL https://api.uselinger.app/v1. Machine-readable description: OpenAPI 3.1, for Postman or a code generator. Questions: hello@uselinger.app.

Authentication

Send your key in the Authorization header: Bearer lk_live_…. Brand owners create keys in the brand portal under Settings → API keys, choose what each key can do, and can revoke a key at any time. A key is shown once, when it’s created. Keep it on your server, never in a web page or app.

  • offers:read: Read offers
  • codes:read: Read offer codes
  • follows:read: Read followers (only people who consented to your brand)
  • redemptions:write: Report redemptions

Pages, limits and errors

  • Lists return data, has_more and next_cursor. Pass next_cursor as starting_after to get the next page. Up to 500 per page.
  • Up to 120 requests a minute per key; more get a 429.
  • Errors are JSON: {"error": "missing_scope", "detail": …}. Times are ISO 8601 with a time zone. Money is in pounds.
  • Followers are only people who consented to your brand and haven’t unsubscribed. Treat their emails as personal data.

List offers GET /offers

Your brand's offers at every show.

Scope: offers:read.

Query parameters

FieldTypeNotes
show_idstring (uuid)Only this show.
limitintegerHow many to return, 1 to 500. Default 100.
starting_afterstring (uuid)The next_cursor from the previous page.

Example

curl https://api.uselinger.app/v1/offers?limit=100 \
  -H "Authorization: Bearer $LINGER_API_KEY"

Responses

  • 200 One page, oldest id first.
  • 400 The query or body didn't validate; detail names the fields.
  • 401 Missing, unknown or revoked API key.
  • 403 The key doesn't have the scope this call needs.
  • 429 More than 120 requests a minute with this key.

List codes GET /codes

Offer codes from your pools, with whether each was given out or used.

Scope: codes:read.

Query parameters

FieldTypeNotes
offer_idstring (uuid)Only this offer.
status"unclaimed" | "claimed" | "redeemed"unclaimed (still in the pool), claimed (given to a follower) or redeemed.
limitintegerHow many to return, 1 to 500. Default 100.
starting_afterstring (uuid)The next_cursor from the previous page.

Example

curl https://api.uselinger.app/v1/codes?limit=100 \
  -H "Authorization: Bearer $LINGER_API_KEY"

Responses

  • 200 One page, oldest id first.
  • 400 The query or body didn't validate; detail names the fields.
  • 401 Missing, unknown or revoked API key.
  • 403 The key doesn't have the scope this call needs.
  • 429 More than 120 requests a minute with this key.

List followers GET /follows

People who followed your brand and haven't withdrawn consent, with the code they were given. Anyone who unsubscribes drops out.

Scope: follows:read.

Query parameters

FieldTypeNotes
show_idstring (uuid)Only this show.
sincestring (date-time)Only follows from this time on (ISO 8601).
limitintegerHow many to return, 1 to 500. Default 100.
starting_afterstring (uuid)The next_cursor from the previous page.

Example

curl https://api.uselinger.app/v1/follows?limit=100 \
  -H "Authorization: Bearer $LINGER_API_KEY"

Responses

  • 200 One page, oldest id first.
  • 400 The query or body didn't validate; detail names the fields.
  • 401 Missing, unknown or revoked API key.
  • 403 The key doesn't have the scope this call needs.
  • 429 More than 120 requests a minute with this key.

Report a redemption POST /redemptions

Tell Linger a code was used, from your shop, till or agency. Safe to retry: the same order_id and code are recorded once.

Scope: redemptions:write.

Body

FieldTypeNotes
codestring
channel"online" | "in_store"Default "online".
order_idstringYour order reference. Repeating a call with the same order_id and code is safe.
order_valuenumber
currencystring
redeemed_atstring (date-time)Defaults to now.

Example

curl -X POST https://api.uselinger.app/v1/redemptions \
  -H "Authorization: Bearer $LINGER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"code":"LNG-7F3KQ9","channel":"online","order_id":"1001","order_value":18.5}'

Responses

  • 200 Already recorded; nothing changed.
  • 201 Recorded.
  • 400 The query or body didn't validate; detail names the fields.
  • 401 Missing, unknown or revoked API key.
  • 403 The key doesn't have the scope this call needs.
  • 404 No such code for your brand.
  • 409 The code exists but was never given to a follower.
  • 429 More than 120 requests a minute with this key.

Webhooks

Instead of asking the API for changes, add an endpoint in the brand portal (Settings → Webhooks) and Linger sends a POST the moment something happens. Each endpoint chooses its events:

  • follow.created: Someone follows you (with their email and consent).
  • code.issued: A follower is given an offer code.
  • code.redeemed: A code is used, from any shop, till or the staff page.
  • consent.withdrawn: Someone unsubscribes: stop emailing them.

The body is JSON: id (unique per event; use it to ignore repeats), type, created_at, brand_id and data. In follow.created, data is a Follow (below); code.issued adds the code and offer; code.redeemed has the code, offer, channel, source and order value; consent.withdrawn has the email to stop contacting. Followers are only people who consented to your brand.

{
  "id": "evt_3f1c0d9b7a2e4c55b0e1f2a3b4c5d6e7",
  "type": "follow.created",
  "created_at": "2027-05-14T10:21:07.312Z",
  "brand_id": "8b0c…",
  "data": {
    "id": "c2d1…",
    "email": "sam@example.com",
    "show": { "id": "4a9e…", "name": "Foodies Bristol" },
    "stand_code": "CKC",
    "followed_at": "2027-05-14T10:21:07.120Z",
    "consent": { "granted_at": "2027-05-14T10:21:07.101Z", "wording_version": "checkbox-2026-10-draft2" }
  }
}

Checking the signature

Every request carries Linger-Signature: t=…,v1=…: an HMAC-SHA256 of <t>.<raw body> with your endpoint’s signing secret (shown once, when you add the endpoint). Recompute it from the raw body, compare in constant time, and reject timestamps more than five minutes old.

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as received, before JSON.parse.
export function fromLinger(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(v1 ?? "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

Retries

Answer with any 2xx within 5 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then marked failed; the portal shows each endpoint’s recent deliveries. Events can arrive more than once or out of order, so key on id. Use “Send test event” in the portal to try your endpoint with a webhook.test event.

Objects

Offer

What a stand gives people who follow your brand. The newest one that hasn't expired is live.

FieldTypeNotes
idstring (uuid)
showShow
titlestring
termsstring | null
value_type"fixed" | "percent" | "free_item" | "other"
valuenumber | nullPounds for fixed, percent for percent, otherwise null.
expires_atstring (date-time) | null
created_atstring (date-time)

Show

A food or drink show your brand has a stand at.

FieldTypeNotes
idstring (uuid)
namestring
starts_onstring (date)
ends_onstring (date)

Code

A single-use offer code from the pool Linger generates for each offer.

FieldTypeNotes
codestring
offer_idstring (uuid)
status"unclaimed" | "claimed" | "redeemed"
claimed_atstring (date-time) | nullWhen it was given to a follower.
redeemed_atstring (date-time) | null

Follow

Someone who followed your brand and hasn't withdrawn consent. People who unsubscribe drop out of this list.

FieldTypeNotes
idstring (uuid)
emailstring (email)
showobject
stand_codestring
followed_atstring (date-time)
consentobject
codestring | nullThe offer code this follower was given, if any.

RedemptionResult

FieldTypeNotes
status"recorded" | "duplicate"
idstring (uuid)