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.