Developers
Getting started with the Forta Pay API
From an API key to an invoice with a payment link, payments, credits and events — every example below is run by our test suite against the sandbox on each change.
Contents
- 1. Keys: sandbox and live
- 2. Idempotency-Key
- 3. Error format
- 4. Cursor pagination
- 5. Flow: debtor → invoice → payment link
- 6. Flow: mark as paid (payment outside Forta Pay)
- 7. Flow: credit
- 8. Flow: withdraw a registration
- 9. Flow: subscribe to events (webhooks and /changes)
Reference (every path, field and error): API reference · Changelog
1. Keys: sandbox and live
Every call goes to https://api.fortapay.dk/v1. The key decides the environment: fp_test_… is a sandbox account (virtual clock, simulated payment rails, nothing reaches real debtors, /sandbox/* exists), fp_live_… is live. The contract is the same.
Keys are issued by an administrator under Settings → API keys and shown once. A key is bound to one creditor and carries read and/or write. Send it as Authorization: Bearer … — never in a URL. Rotate with overlap from the same page.
The examples need bash, curl (7.76 or newer), jq and uuidgen. Run the blocks in order in one terminal.
# Sæt din sandbox-nøgle (fp_test_…) / set your sandbox key (fp_test_…)
export FP_API=${FP_API:-https://api.fortapay.dk/v1}
export FP_KEY=${FP_KEY:?FP_KEY: din/your fp_test_ key}
# fp METODE STI [KROP] — et kald med nøgle og Idempotency-Key; stopper ved 4xx/5xx og viser fejlen
fp() {
local metode=$1 sti=$2; shift 2
local h=(-H "Authorization: Bearer $FP_KEY" -H "Accept: application/json")
if [ "$metode" != GET ]; then h+=(-H "Idempotency-Key: ${IDEM:-$(uuidgen)}"); fi
if [ $# -gt 0 ]; then h+=(-H "Content-Type: application/json" --data-binary "$1"); fi
curl -sS --fail-with-body -X "$metode" "${h[@]}" "$FP_API$sti"
}
fp GET /account | jq -c '{id, environment}'
CREDITOR_ID=$(fp GET /creditors | jq -r '.data[0].id')
RUN=$(date +%s) # gør external_ref unik pr. kørsel / unique per run
NOW=$(fp GET /sandbox/clock | jq -r .now) # sandbox-urets tid / the sandbox clock
TODAY=$(fp GET /sandbox/clock | jq -r .today)
DUE=$(jq -rn --arg d "$TODAY" '$d + "T00:00:00Z" | fromdate + 14*86400 | strftime("%Y-%m-%d")')
echo "{\"creditor\": \"$CREDITOR_ID\", \"today\": \"$TODAY\", \"due\": \"$DUE\"}"
2. Idempotency-Key
Every POST, PATCH, PUT and DELETE requires the Idempotency-Key header with a UUID. The same key and the same body within 24 hours returns the same response — errors included — and nothing happens twice. So reuse the key when you retry after a timeout. The same key with a different body is rejected with 422 idempotency_key_reused. Missing header: 400 idempotency_key_missing.
KEY=$(uuidgen)
A=$(IDEM=$KEY fp POST /debtors @- <<JSON | jq -r .id
{"creditor_id": "$CREDITOR_ID", "name": "Idempotens ApS", "type": "business", "external_ref": "idem-$RUN"}
JSON
)
B=$(IDEM=$KEY fp POST /debtors @- <<JSON | jq -r .id
{"creditor_id": "$CREDITOR_ID", "name": "Idempotens ApS", "type": "business", "external_ref": "idem-$RUN"}
JSON
)
echo "{\"same_id\": $([ "$A" = "$B" ] && echo true || echo false)}"
3. Error format
Every error is application/problem+json (RFC 9457). Build your code on code — a stable, machine-readable string — and on the HTTP status; detail is for humans and its wording may change. Field errors are in errors[] with field, code and message. Every response carries request_id (also in the X-Request-Id header) — include it when you contact us.
Errors any call can return: 400 (e.g. validation_failed in query parameters, invalid_cursor, unsupported_filter), 401 unauthorized, 403 insufficient_scope, 404 not_found, 422 validation_failed in the body. Errors specific to one operation are listed on that operation in the reference.
# Samme nøgle, anden krop → 422 idempotency_key_reused. curl stopper; vi viser fejlen og går videre.
# Same key, different body → 422 idempotency_key_reused. curl fails; we print the problem and continue.
IDEM=$KEY fp POST /debtors @- <<JSON || true
{"creditor_id": "$CREDITOR_ID", "name": "Et andet navn", "type": "business", "external_ref": "idem-$RUN"}
JSON
echo
fp GET /invoices/inv_FINDESIKKE | jq -c '{status, code}' || true
4. Cursor pagination
Lists return {"data": […], "next_cursor": …, "has_more": …}. Send next_cursor back as cursor until has_more is false. limit is 1–200 (default 50). The cursor is opaque — store it, don't build it. Ordering is stable, so pages neither skip nor repeat rows. Unknown filters are rejected with 400 unsupported_filter rather than ignored.
CURSOR=""
while :; do
SIDE=$(fp GET "/invoices?creditor_id=$CREDITOR_ID&limit=2${CURSOR:+&cursor=$CURSOR}")
jq -r '.data[] | [.id, .status, .balance.amount] | @tsv' <<<"$SIDE"
[ "$(jq -r .has_more <<<"$SIDE")" = true ] || break
CURSOR=$(jq -r .next_cursor <<<"$SIDE")
done
echo '{"done": true}'
5. Flow: debtor → invoice → payment link
Create the debtor, then the invoice. external_ref is your own number: posting the same external_ref again returns the existing record (200) instead of a duplicate (201). Amounts are integers in the minor unit (øre).
DEBTOR_ID=$(fp POST /debtors @- <<JSON | jq -r .id
{"creditor_id": "$CREDITOR_ID", "name": "Jens Hansen", "type": "person",
"email": "jens.hansen@example.com", "external_ref": "kunde-$RUN"}
JSON
)
echo "{\"debtor_id\": \"$DEBTOR_ID\"}"
INVOICE_ID=$(fp POST /invoices @- <<JSON | jq -r .id
{"creditor_id": "$CREDITOR_ID", "debtor_id": "$DEBTOR_ID", "external_ref": "faktura-$RUN",
"issue_date": "$TODAY", "due_date": "$DUE",
"total": {"amount": 125000, "currency": "DKK"}, "vat_total": {"amount": 25000, "currency": "DKK"}}
JSON
)
fp GET "/invoices/$INVOICE_ID" | jq -c '{id, status, balance}'
fp POST /hosted-sessions @- <<JSON | jq -c '{id, status, url_set: (.url != null), expires_at}'
{"creditor_id": "$CREDITOR_ID", "debtor_id": "$DEBTOR_ID", "purpose": "pay", "invoice_id": "$INVOICE_ID"}
JSON
Send url to the debtor — or let Forta Pay do it with "send_via": ["email"]. When the debtor pays you receive the payment.succeeded event and the invoice moves to paid.
6. Flow: mark as paid (payment outside Forta Pay)
If the debtor paid another way — bank transfer, cash, card terminal — register it with mark-paid. A partial amount is allowed; the invoice then stays open with the remainder. If the invoice is already closed the API returns 409 invoice_not_payable with current_status. If a Betalingsservice collection has already been submitted it returns 409 charge_in_flight.
fp POST "/invoices/$INVOICE_ID/mark-paid" @- <<JSON | jq -c '{status, balance}'
{"amount": {"amount": 125000, "currency": "DKK"}, "paid_at": "$NOW",
"payment_method": {"rail": "bank_transfer", "source": "external", "instrument": "bank_transfer"},
"external_ref": "bank-$RUN", "note": "Betalt via netbank"}
JSON
7. Flow: credit
A credit reduces the invoice balance by the amount you send. Crediting the full remainder moves the invoice to credited. More than the remainder is rejected with 422 credit_exceeds_balance.
INVOICE2_ID=$(fp POST /invoices @- <<JSON | jq -r .id
{"creditor_id": "$CREDITOR_ID", "debtor_id": "$DEBTOR_ID", "external_ref": "faktura-$RUN-2",
"issue_date": "$TODAY", "due_date": "$DUE", "total": {"amount": 40000, "currency": "DKK"}}
JSON
)
# delvis kreditering: 100,00 kr. / partial credit: DKK 100.00
fp POST "/invoices/$INVOICE2_ID/credit" @- <<JSON | jq -c '{status, balance}'
{"amount": {"amount": 10000, "currency": "DKK"}, "reason": "Rabat aftalt", "external_ref": "kreditnota-$RUN"}
JSON
fp POST "/invoices/$INVOICE2_ID/credit" @- <<JSON | jq -c '{status, balance}'
{"amount": {"amount": 30000, "currency": "DKK"}, "reason": "Ordren annulleret", "external_ref": "kreditnota-$RUN-b"}
JSON
8. Flow: withdraw a registration
If you registered a payment with mark-paid that should not have been registered, withdraw it. The invoice reopens with the full amount. Only payments you registered yourself (payment_method.source = external) can be withdrawn; a payment Forta Pay observed itself returns 409 not_a_registration. A second attempt returns 409 registration_already_withdrawn — meaning “already done”.
INVOICE3_ID=$(fp POST /invoices @- <<JSON | jq -r .id
{"creditor_id": "$CREDITOR_ID", "debtor_id": "$DEBTOR_ID", "external_ref": "faktura-$RUN-3",
"issue_date": "$TODAY", "due_date": "$DUE", "total": {"amount": 20000, "currency": "DKK"}}
JSON
)
fp POST "/invoices/$INVOICE3_ID/mark-paid" @- <<JSON >/dev/null
{"amount": {"amount": 20000, "currency": "DKK"}, "paid_at": "$NOW",
"payment_method": {"rail": "pos", "source": "external", "instrument": "cash"}, "external_ref": "kasse-$RUN"}
JSON
PAYMENT_ID=$(fp GET "/payments?external_ref=kasse-$RUN" | jq -r '.data[0].id')
echo "{\"payment_id\": \"$PAYMENT_ID\"}"
fp POST "/payments/$PAYMENT_ID/withdraw-registration" @- <<JSON | jq -c '{kind, reopens_invoice}'
{"reason": "Registreret ved en fejl — kunden betalte ikke i kassen"}
JSON
fp GET "/invoices/$INVOICE3_ID" | jq -c '{status, balance}'
9. Flow: subscribe to events (webhooks and /changes)
There are two paths and you need both. Webhooks push each event to your URL (https) right away. GET /changes is the same feed, pulled by you, with a binding promise: every event after your cursor, in order, without gaps. A lost webhook is therefore a delay, not a loss — webhooks make you fast, /changes makes you correct. Persist the cursor after every page. 409 cursor_ahead_of_feed or 410 cursor_too_old means the cursor is unusable; get a fresh one with GET /changes/cursor and reconcile. A webhook only receives events that happen after it was created — create it before you create invoices; fetch older events with /changes (the example below starts from the beginning).
# Gem secret med det samme — den vises kun her / store the secret now — it is shown only once
ENDPOINT=$(fp POST /webhook-endpoints @- <<JSON
{"url": "https://erp.example.com/webhooks/forta-pay", "creditor_id": "$CREDITOR_ID",
"event_types": ["invoice.*", "payment.*"], "description": "Vores ERP"}
JSON
)
WEBHOOK_SECRET=$(jq -r .secret <<<"$ENDPOINT")
jq -c '{id, status, event_types, secret_prefix: (.secret[:6])}' <<<"$ENDPOINT"
# Første gang: start fra begyndelsen (ingen cursor). Gem next_cursor varigt efter hver side.
# First run: start from the beginning (no cursor). Persist next_cursor after every page.
CURSOR=""
while :; do
SIDE=$(fp GET "/changes?limit=200${CURSOR:+&cursor=$CURSOR}")
jq -r '.data[] | [.seq, .type, (.data.object.id // "")] | @tsv' <<<"$SIDE" | tail -3
CURSOR=$(jq -r .next_cursor <<<"$SIDE")
[ "$(jq -r .has_more <<<"$SIDE")" = true ] || break
done
echo "{\"cursor_set\": $([ -n "$CURSOR" ] && echo true || echo false)}"
import hashlib
import hmac
import time
def verify_forta_pay_signature(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool:
"""True when the webhook really comes from Forta Pay. `body` is the RAW request body (bytes)."""
h = {k.lower(): v for k, v in headers.items()}
event_id = h.get("forta-pay-event-id", "")
timestamp = h.get("forta-pay-timestamp", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
return False
signed = f"{event_id}.{timestamp}.".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# During a secret rotation the header carries two values: "v1=<new> v1=<old>"
return any(part.startswith("v1=") and hmac.compare_digest(part[3:], expected)
for part in h.get("forta-pay-signature", "").split())
Every delivery is signed: the headers Forta-Pay-Event-Id, Forta-Pay-Timestamp (unix seconds) and Forta-Pay-Signature = v1= + hex HMAC-SHA256 keyed with your whsec_… over {event-id}.{timestamp}.{raw body}. Reject deliveries older than 5 minutes. Reply 2xx quickly; otherwise we retry after 1 m, 5 m, 30 m, 2 h, 6 h, 12 h, 24 h and then daily for up to 7 days. The same event may arrive more than once — deduplicate on Forta-Pay-Event-Id. Tolerate unknown event types and unknown fields. In the sandbox you can create an endpoint with "mode": "inspector" and no URL, and inspect deliveries with GET /webhook-deliveries/{id}.