Udviklere
Kom i gang med Forta Pay API'et
Fra API-nøgle til faktura med betalingslink, betalinger, krediteringer og hændelser — hvert eksempel herunder køres af vores prøver mod sandboxen ved hver ændring.
Indhold
- 1. Nøgler: sandbox og live
- 2. Idempotency-Key
- 3. Fejlformat
- 4. Paginering med cursor
- 5. Forløb: debitor → faktura → betalingslink
- 6. Forløb: markér betalt (betaling uden om Forta Pay)
- 7. Forløb: kreditér
- 8. Forløb: træk en registrering tilbage
- 9. Forløb: abonnér på hændelser (webhooks og /changes)
Reference (alle stier, felter og fejl): API-referencen · Ændringslog
1. Nøgler: sandbox og live
Alle kald går til https://api.fortapay.dk/v1. Nøglen afgør miljøet: fp_test_… er en sandbox-konto (virtuelt ur, simulerede betalingsrør, intet sendes til rigtige debitorer, /sandbox/* findes), fp_live_… er live. Kontrakten er den samme.
Nøgler udstedes af en administrator under Indstillinger → API-nøgler og vises én gang. En nøgle er bundet til én kreditor og har rettighederne read og/eller write. Send den som Authorization: Bearer … — aldrig i en URL. Roter med overlap fra samme side.
Eksemplerne kræver bash, curl (7.76 eller nyere), jq og uuidgen. Kør blokkene i rækkefølge i samme 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
Alle POST, PATCH, PUT og DELETE kræver headeren Idempotency-Key med et UUID. Samme nøgle og samme krop inden for 24 timer giver det samme svar — også en fejl — uden at noget sker to gange. Brug derfor samme nøgle, når du genforsøger efter en timeout. Samme nøgle med en anden krop afvises med 422 idempotency_key_reused. Mangler headeren: 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. Fejlformat
Alle fejl er application/problem+json (RFC 9457). Byg din kode på code — en stabil, maskinlæsbar streng — og på HTTP-status; detail er til mennesker og kan ændre ordlyd. Feltfejl står i errors[] med field, code og message. Hvert svar bærer request_id (også i headeren X-Request-Id) — send det med, hvis du skriver til os.
Fejl der kan komme på alle kald: 400 (fx validation_failed i query-parametre, invalid_cursor, unsupported_filter), 401 unauthorized, 403 insufficient_scope, 404 not_found, 422 validation_failed i kroppen. Fejl der hører til én operation, står på operationen i referencen.
# 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. Paginering med cursor
Lister svarer {"data": […], "next_cursor": …, "has_more": …}. Send next_cursor tilbage som cursor, indtil has_more er false. limit er 1–200 (standard 50). Markøren er uigennemsigtig — gem den, byg den ikke selv. Sorteringen er stabil, så en side springer ikke over og gentager ikke rækker. Ukendte filtre afvises med 400 unsupported_filter i stedet for at blive ignoreret.
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. Forløb: debitor → faktura → betalingslink
Opret debitoren, derefter fakturaen. external_ref er dit eget nummer: samme external_ref igen giver den eksisterende række (200) i stedet for en dublet (201). Beløb er heltal i ø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 til debitoren — eller lad Forta Pay gøre det med "send_via": ["email"]. Betaler debitoren, kommer det som hændelsen payment.succeeded, og fakturaen går til paid.
6. Forløb: markér betalt (betaling uden om Forta Pay)
Betalte debitoren på en anden måde — bankoverførsel, kontant, terminal — registrerer du det med mark-paid. Et delbeløb er tilladt; fakturaen står så åben med restbeløbet. Er fakturaen allerede lukket, svarer API'et 409 invoice_not_payable med current_status. Er et Betalingsservice-træk allerede indleveret, svarer det 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. Forløb: kreditér
En kreditering nedskriver fakturaens saldo med det beløb du sender. Krediteres hele restbeløbet, går fakturaen til credited. Mere end restbeløbet afvises med 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. Forløb: træk en registrering tilbage
Har du registreret en betaling med mark-paid, som ikke skulle have været registreret, trækker du den tilbage. Fakturaen åbner igen med det fulde beløb. Kun betalinger du selv har registreret (payment_method.source = external) kan trækkes tilbage; en betaling Forta Pay selv har set, giver 409 not_a_registration. Anden gang svarer API'et 409 registration_already_withdrawn — det betyder «allerede gjort».
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. Forløb: abonnér på hændelser (webhooks og /changes)
Der er to veje, og du skal bruge begge. Webhooks skubber hver hændelse til din URL (https) med det samme. GET /changes er det samme feed som du selv henter, med et bindende løfte: alle hændelser efter din markør, i rækkefølge, uden huller. En tabt webhook er dermed en forsinkelse, ikke et tab — webhooks gør dig hurtig, /changes gør dig rigtig. Gem markøren efter hver side. 409 cursor_ahead_of_feed eller 410 cursor_too_old betyder at markøren ikke kan bruges; hent en ny med GET /changes/cursor og afstem. Et webhook får kun hændelser, der sker efter det er oprettet — opret det, før du laver fakturaer; de ældre henter du med /changes (eksemplet nedenfor starter fra begyndelsen).
# 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())
Hver levering er signeret: headerne Forta-Pay-Event-Id, Forta-Pay-Timestamp (unix-sekunder) og Forta-Pay-Signature = v1= + hex HMAC-SHA256 med din whsec_… som nøgle over {event-id}.{timestamp}.{rå krop}. Afvis leveringer der er ældre end 5 minutter. Svar 2xx hurtigt; ellers forsøger vi igen efter 1 m, 5 m, 30 m, 2 t, 6 t, 12 t, 24 t og derefter dagligt i op til 7 døgn. Samme hændelse kan komme mere end én gang — dedupliker på Forta-Pay-Event-Id. Tolerér ukendte hændelsestyper og ukendte felter. I sandbox kan du oprette et endpoint med "mode": "inspector" uden URL og se leveringerne med GET /webhook-deliveries/{id}.