Quickstart

Read, then update, your Fideliya business from your own server in six steps. Every example below is a file that our tests run against the API on every change, so what you copy is what works.

1. Get a key

In the dashboard, open API & Webhooks, then API keys, and create a TEST key first (Enterprise; the owner creates keys). A test key (fdl_test_…) works on your sandbox only: a demo pass, 25 demo customers and 200 demo scans. Nothing it does reaches a real customer: no email, no wallet update, and webhooks only as test events (livemode: false) to endpoints you opt in. When your code works, create a live key: Read only to read, Read & write to also enrol customers, record scans, issue gift cards and take payments from them. A key is shown once: keep it on your server, never in a browser or an app.

2. Check the key works

GET /v1/me names the key's business, its plan and its rate limits.

1-me.sh

#!/usr/bin/env bash
# Step 2: check your key works. The answer names its business, plan and limits.
# Needs curl 7.76 or later (--fail-with-body).
set -euo pipefail
: "${FIDELIYA_API_KEY:?Set FIDELIYA_API_KEY to your key}"
BASE="${FIDELIYA_BASE_URL:-https://www.fideliyapass.com/api}"

# On an error, curl prints the problem+json (its `code` says why) and exits non-zero.
curl --fail-with-body -sS --max-time 30 "$BASE/v1/me" \
  -H "Authorization: Bearer $FIDELIYA_API_KEY"

me.ts

// Step 2: check your key works. The answer names its business, plan and limits.
// Run it: FIDELIYA_API_KEY=… npx tsx me.ts

const BASE = process.env.FIDELIYA_BASE_URL ?? 'https://www.fideliyapass.com/api';

export async function me(apiKey = process.env.FIDELIYA_API_KEY ?? '', fetchImpl: typeof fetch = fetch, base = BASE) {
  const res = await fetchImpl(`${base}/v1/me`, {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  const text = await res.text();
  if (!res.ok) {
    // Errors are problem+json with a stable `code` (see /developers/errors);
    // a proxy in the way may answer something else, so the status comes first.
    let code = '';
    try { code = JSON.parse(text).code ?? ''; } catch { /* not JSON */ }
    throw new Error(`${res.status} ${code || text.slice(0, 200)}`.trim());
  }
  return JSON.parse(text) as { business: { id: string; name: string }; plan: string; scopes: string[] };
}

if (process.argv[1]?.endsWith('me.ts')) {
  me()
    .then((k) => console.log(`${k.business.name}: plan ${k.plan}, scopes ${k.scopes.join(', ')}`))
    .catch((e: Error) => { console.error(e.message); process.exitCode = 1; });
}

3. List customers

Lists are newest first, up to 100 per page (limit), each answer with has_more and next_cursor.

2-list-customers.sh

#!/usr/bin/env bash
# Step 3: the business's customers, newest first, two per page.
# Needs curl 7.76 or later (--fail-with-body).
set -euo pipefail
: "${FIDELIYA_API_KEY:?Set FIDELIYA_API_KEY to your key}"
BASE="${FIDELIYA_BASE_URL:-https://www.fideliyapass.com/api}"

curl --fail-with-body -sS --max-time 30 "$BASE/v1/customers?limit=2" \
  -H "Authorization: Bearer $FIDELIYA_API_KEY"

list-customers.ts

// Step 3: one page of the business's customers, newest first.
// Run it: FIDELIYA_API_KEY=… npx tsx list-customers.ts

const BASE = process.env.FIDELIYA_BASE_URL ?? 'https://www.fideliyapass.com/api';

export interface Customer {
  id: string;
  name: string | null;
  email: string | null;
  created_at: string | null;
}

export interface Page {
  data: Customer[];
  has_more: boolean;
  next_cursor: string | null;
}

export async function listCustomers(
  { limit = 25, cursor }: { limit?: number; cursor?: string | null } = {},
  apiKey = process.env.FIDELIYA_API_KEY ?? '',
  fetchImpl: typeof fetch = fetch,
  base = BASE,
): Promise<Page> {
  const url = new URL(`${base}/v1/customers`);
  url.searchParams.set('limit', String(limit));
  if (cursor) url.searchParams.set('cursor', cursor);
  const res = await fetchImpl(url, { headers: { Authorization: `Bearer ${apiKey}` } });
  const text = await res.text();
  if (!res.ok) {
    // problem+json with a stable `code`; anything else (a proxy) by its status
    let code = '';
    try { code = JSON.parse(text).code ?? ''; } catch { /* not JSON */ }
    throw new Error(`${res.status} ${code || text.slice(0, 200)}`.trim());
  }
  return JSON.parse(text) as Page;
}

if (process.argv[1]?.endsWith('list-customers.ts')) {
  listCustomers({ limit: 2 })
    .then((p) => console.log(p.data.map((c) => c.name), p.next_cursor ? 'more…' : 'that is all'))
    .catch((e: Error) => { console.error(e.message); process.exitCode = 1; });
}

4. Every page

Pass next_cursor back as cursor until it is null. A cursor belongs to its list and its filters: never edit one, and start again if you change the query.

3-every-page.sh

#!/usr/bin/env bash
# Step 4: every page. Pass each answer's next_cursor back as `cursor`
# until it is null. Needs curl and jq.
set -euo pipefail
: "${FIDELIYA_API_KEY:?Set FIDELIYA_API_KEY to your key}"
BASE="${FIDELIYA_BASE_URL:-https://www.fideliyapass.com/api}"

cursor=""
total=0
while :; do
  # The body, then the status on its own last line.
  answer=$(curl -sS --max-time 30 -G "$BASE/v1/customers" \
    -H "Authorization: Bearer $FIDELIYA_API_KEY" \
    --data-urlencode "limit=100" \
    ${cursor:+--data-urlencode "cursor=$cursor"} \
    -w '\n%{http_code}')
  status=${answer##*$'\n'}
  page=${answer%$'\n'*}
  if [ "$status" != "200" ]; then
    echo "HTTP $status: $page" >&2   # the problem+json: its `code` says why
    exit 1
  fi
  total=$((total + $(echo "$page" | jq -e '.data | arrays | length')))
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
done
echo "$total customers"

every-customer.ts

// Step 4: every customer. Pass each page's next_cursor back as `cursor`
// until it is null. A cursor belongs to its list and its filters: never edit
// one, and start again if you change the query.
// Run it: FIDELIYA_API_KEY=… npx tsx every-customer.ts

import { listCustomers, type Customer } from './list-customers';

export async function everyCustomer(
  apiKey = process.env.FIDELIYA_API_KEY ?? '',
  fetchImpl: typeof fetch = fetch,
  base?: string,
  limit = 100,
): Promise<Customer[]> {
  const all: Customer[] = [];
  const seen = new Set<string>();
  let cursor: string | null = null;
  do {
    const page = await listCustomers({ limit, cursor }, apiKey, fetchImpl, base);
    all.push(...page.data);
    cursor = page.next_cursor;
    if (cursor && seen.has(cursor)) throw new Error('the same cursor came back twice; stopping');
    if (cursor) seen.add(cursor);
  } while (cursor);
  return all;
}

if (process.argv[1]?.endsWith('every-customer.ts')) {
  everyCustomer()
    .then((all) => console.log(`${all.length} customers`))
    .catch((e: Error) => { console.error(e.message); process.exitCode = 1; });
}

5. Record a scan

A write needs a key with the Read & write permission and an Idempotency-Key header (a UUID per write). Retry a write with the same key and it is done once: the first answer comes back, with Idempotent-Replayed: true. A scan follows the counter's rules, updates the customer's wallet card and fires your webhooks.

4-record-scan.sh

#!/usr/bin/env bash
# Step 5: record a visit on a card (its id is the first argument). Needs a key
# with the Read & write permission, curl 7.76 or later, and uuidgen.
# The Idempotency-Key makes a retry safe: the same key never counts twice.
# A second visit on the same card within 15 minutes answers 409 recent_visit and
# records nothing; if it really is a new visit, add "confirm_repeat": true.
set -euo pipefail
: "${FIDELIYA_API_KEY:?Set FIDELIYA_API_KEY to your key}"
BASE="${FIDELIYA_BASE_URL:-https://www.fideliyapass.com/api}"
card="${1:?Give the card id: 4-record-scan.sh <card id>}"
idem="$(uuidgen)"

curl --fail-with-body -sS --max-time 30 -X POST "$BASE/v1/scans" \
  -H "Authorization: Bearer $FIDELIYA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $idem" \
  --data "{\"card_id\": \"$card\", \"kind\": \"visit\"}"

record-scan.ts

// Step 5: record a visit on a card, as the counter would. Needs a key with the
// Read & write permission. The Idempotency-Key makes a retry safe: send the
// same key again (after a timeout, say) and the visit is still counted once.
// Run it: FIDELIYA_API_KEY=… npx tsx record-scan.ts <card id>

import { randomUUID } from 'node:crypto';

const BASE = process.env.FIDELIYA_BASE_URL ?? 'https://www.fideliyapass.com/api';

export interface ScanResult {
  kind: 'visit' | 'points' | 'redeem';
  visits_added: number;
  reward_earned: boolean;
  card: { id: string; visits: number; points: number };
}

export async function recordVisit(
  cardId: string,
  idempotencyKey: string = randomUUID(),
  apiKey = process.env.FIDELIYA_API_KEY ?? '',
  fetchImpl: typeof fetch = fetch,
  base = BASE,
): Promise<{ result: ScanResult; replayed: boolean }> {
  const res = await fetchImpl(`${base}/v1/scans`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
      // Keep this key with the request: retrying with it never counts twice.
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify({ card_id: cardId, kind: 'visit' }),
  });
  const text = await res.text();
  if (!res.ok) {
    let code = '';
    try { code = JSON.parse(text).code ?? ''; } catch { /* not JSON */ }
    throw new Error(`${res.status} ${code || text.slice(0, 200)}`.trim());
  }
  return { result: JSON.parse(text) as ScanResult, replayed: res.headers.get('Idempotent-Replayed') === 'true' };
}

if (process.argv[1]?.endsWith('record-scan.ts')) {
  recordVisit(process.argv[2] ?? '')
    .then(({ result }) => console.log(`visits now ${result.card.visits}${result.reward_earned ? ', reward earned' : ''}`))
    .catch((e: Error) => { console.error(e.message); process.exitCode = 1; });
}

6. Take a payment from a gift card

POST /v1/gift_cards/{id}/payments takes an amount off a gift card exactly as the scanner does at the counter, with a key allowed to take payments from gift cards (the gift_cards:spend scope: "Take payments from gift cards" on the key form, or Full access; Read & write alone only issues cards) and an Idempotency-Key. The amount is amount_minor, in minor units of the card's own currency (1250 is 12.50 EUR; on a SESSIONS card, 2 is two sessions), and the currency must be the card's. More than the balance is refused (409 insufficient_balance, with what the card holds) and nothing is taken; exactly the balance uses the card up. A card not claimed yet, cancelled or used up is refused with its own code. The card in the customer's wallet updates, and gift_card.spent fires. There is no undo through the API: the five-second Undo belongs to the scanner that took the payment.

5-gift-card-payment.sh

#!/usr/bin/env bash
# Step 6: take a payment from a gift card, as the till would. Arguments: the
# gift card's id, the amount in minor units (1250 is 12.50), the card's
# currency (EUR when left out) and your ticket number (optional). Needs a key
# allowed to take payments from gift cards (the gift_cards:spend scope: tick
# "Take payments from gift cards", or choose Full access), curl 7.76 or later,
# and uuidgen.
# The Idempotency-Key makes a retry safe: the same key never takes the money twice.
set -euo pipefail
: "${FIDELIYA_API_KEY:?Set FIDELIYA_API_KEY to your key}"
BASE="${FIDELIYA_BASE_URL:-https://www.fideliyapass.com/api}"
card="${1:?Give the gift card id: 5-gift-card-payment.sh <gift card id> <amount in minor units> [currency] [ticket]}"
amount="${2:?Give the amount in minor units: 1250 for 12.50}"
currency="${3:-EUR}"
ticket="${4:-}"
[[ "$amount" =~ ^[0-9]+$ ]] || { echo "The amount is a whole number of minor units: 1250 for 12.50" >&2; exit 2; }
idem="$(uuidgen)"

body="{\"amount_minor\": $amount, \"currency\": \"$currency\""
if [[ -n "$ticket" ]]; then body="$body, \"reference\": \"$ticket\""; fi
body="$body}"

curl --fail-with-body -sS --max-time 30 -X POST "$BASE/v1/gift_cards/$card/payments" \
  -H "Authorization: Bearer $FIDELIYA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $idem" \
  --data "$body"

gift_card_payment.py

"""Step 6: take a payment from a gift card, as the till would.

Needs a key allowed to take payments from gift cards (the gift_cards:spend
scope: tick "Take payments from gift cards", or choose Full access), and
Python 3.8 or later (the standard library only). The amount is in minor units of the card's currency:
1250 is 12.50 EUR. Keep the Idempotency-Key with the payment: retrying with it
(after a timeout, say) never takes the money twice.

Run it: FIDELIYA_API_KEY=... python3 gift_card_payment.py <gift card id> <amount in minor units> [currency] [ticket]
"""
import json
import os
import sys
import urllib.error
import urllib.request
import uuid

BASE = os.environ.get("FIDELIYA_BASE_URL", "https://www.fideliyapass.com/api")


class PaymentRefused(Exception):
    """The API said no: `code` is stable (see /developers/errors), `problem` is the whole answer."""

    def __init__(self, status, code, problem):
        super().__init__(f"{status} {code}")
        self.status = status
        self.code = code
        self.problem = problem


def take_payment(gift_card_id, amount_minor, currency="EUR", reference=None, idempotency_key=None, api_key=None, base=BASE):
    """Take `amount_minor` from the card. Returns (payment, replayed)."""
    body = {"amount_minor": amount_minor, "currency": currency}
    if reference:
        body["reference"] = reference
    request = urllib.request.Request(
        f"{base}/v1/gift_cards/{gift_card_id}/payments",
        data=json.dumps(body).encode("utf-8"),
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key or os.environ.get('FIDELIYA_API_KEY', '')}",
            "Content-Type": "application/json",
            # Keep this key with the payment: retrying with it never takes the money twice.
            "Idempotency-Key": idempotency_key or str(uuid.uuid4()),
        },
    )
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            payment = json.loads(response.read().decode("utf-8"))
            return payment, response.headers.get("Idempotent-Replayed") == "true"
    except urllib.error.HTTPError as error:
        text = error.read().decode("utf-8")
        try:
            problem = json.loads(text)
        except ValueError:
            problem = {"detail": text[:200]}
        raise PaymentRefused(error.code, problem.get("code", ""), problem) from None


if __name__ == "__main__":
    if not os.environ.get("FIDELIYA_API_KEY"):
        sys.exit("Set FIDELIYA_API_KEY to your key")
    if len(sys.argv) < 3 or not sys.argv[2].isdigit():
        sys.exit("Usage: gift_card_payment.py <gift card id> <amount in minor units, 1250 for 12.50> [currency] [ticket]")
    try:
        paid, _ = take_payment(sys.argv[1], int(sys.argv[2]), sys.argv[3] if len(sys.argv) > 3 else "EUR", sys.argv[4] if len(sys.argv) > 4 else None)
    except PaymentRefused as refused:
        if refused.code == "insufficient_balance":
            left = refused.problem["balance"]
            sys.exit(f"refused: the card holds {left['amount']} {left['currency']}")
        sys.exit(f"refused: {refused.status} {refused.code}")
    print(f"taken {paid['amount']} {paid['currency']}, {paid['balance_after']} left")

Next: every operation and its fields in the reference, and what each error means in Errors.

Add to Apple WalletAdd to Google Wallet
© 2026 Fideliya. All rights reserved.
Quickstart: Points From Your Till | Fideliya developers