Spring til indhold
Forta Pay

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. 1. Nøgler: sandbox og live
  2. 2. Idempotency-Key
  3. 3. Fejlformat
  4. 4. Paginering med cursor
  5. 5. Forløb: debitor → faktura → betalingslink
  6. 6. Forløb: markér betalt (betaling uden om Forta Pay)
  7. 7. Forløb: kreditér
  8. 8. Forløb: træk en registrering tilbage
  9. 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.

bash
# 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}'
bash
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.

bash
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.

bash
# 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.

bash
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.

bash
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\"}"
bash
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}'
bash
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.

bash
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.

bash
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
bash
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».

bash
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\"}"
bash
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).

bash
# 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"
bash
# 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)}"
python
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}.