For utviklere

Ett API. Ingen skjult overflate.

Grensesnittet vårt bruker det samme API-et dere får. Er noe mulig i skjermbildet, er det mulig i API-et — det finnes ingen privat bakvei.

Prinsipper

Fire valg som merkes når man integrerer

Ingen av dem er spesielt oppsiktsvekkende. Til sammen er de forskjellen på en integrasjon som holder seg og en som må ettersees.

Én definisjon, fire utganger

Hvert endepunkt defineres én gang. Av den samme definisjonen kommer validatoren, typene, OpenAPI-dokumentet og MCP-verktøyene. Det er derfor dokumentasjonen ikke kan bli utdatert — den er ikke skrevet ved siden av koden, den er generert fra den.

API og MCP holder tritt med hverandre

Et felt som legges til i API-et legges til i MCP-verktøyet i samme endring. En test leser API-ets egne feltlister og går rød begge veier — både når verktøyet mangler noe API-et tar imot, og når det lover noe API-et avviser.

Nøkler med avgrensede rettigheter

API-nøkler har et sett med rettigheter, ikke full tilgang. Skrivende kall tar imot en idempotensnøkkel, så et nettverk som mister svaret kan prøve igjen uten å opprette det samme to ganger.

Laget for å brukes av agenter

MCP-serveren er et tynt skall over det samme API-et — ikke en egen implementasjon som kan drive fra hverandre. En agent kan spørre om hvilke verktøy som finnes, og lese en bruksanvisning skrevet for et språkmodell-publikum.

Første kall

Spør API-et hva det er, før du leser noe om det

Nøkkelen navngir virksomheten selv. Det er ingen virksomhets-ID i stien, og det er ingenting å velge: Authorization: Bearer fo_live_… er hele autentiseringen.

Svaret sier hvem nøkkelen er, hvilken rolle den har, hvilke rettigheter den har — og lister hvert endepunkt som finnes, med operasjons-ID og sammendrag. Det er den raskeste veien til å vite om oppsettet stemmer, og det er det første en agent gjør.

Prefikset fo_live_ er en del av nøkkelen, ikke noe som skal fjernes. Vi lagrer bare en hash av den, så den vises én gang og kan ikke hentes fram igjen.

scopes er rettighetene nøkkelen bærer. En personlig nøkkel kan aldri gjøre mer enn rollen til den som lagde den. En tjenestenøkkel hører til et system og når bare de rutene rettighetene åpner — alt annet er stengt. Rettighetene er import:write (profiler, ordrer, butikker og samtykke fra et annet system), points:read (et medlems poeng og hva en handlekurv gir), points:earn, points:adjust, points:redeem (betale med poeng) og skjemarettighetene. admin:full dekker alle, også de som kommer senere. Nøkler lages under Innstillinger → API-nøkler.

Mangler nøkkelen den rettigheten kallet trenger, får du 403 missing_scope — og hintet navngir rettigheten som må stå på nøkkelen, eller sier at rollen selv ikke rekker så langt. Aldri et avslag du må gjette deg til årsaken til.

Forespørsel
curl https://friendsof.no/api/v1 \
  -H "Authorization: Bearer fo_live_DIN_NØKKEL"
Svar
{
  "ok": true,
  "data": {
    "tenant_id": "6f0c…",
    "auth_method": "api_key",
    "role": "ADMIN",
    "scopes": ["admin:full", "import:write"],
    "skill_url": "/api/v1/skill.md",
    "endpoints": [
      {
        "method": "GET",
        "path": "/api/v1/profiles",
        "operation_id": "fo_list_profiles",
        "summary": "List profiles (the member list), keyset-paginated"
      }
    ]
  }
}

Fire adresser

Kontrakten kan leses, ikke bare beskrives

Alle fire genereres av det samme dokumentet. Ingen av dem er en håndskrevet kopi som kan ta feil av hva API-et gjør.

Dra tabellen sidelengs for å se alle kolonnene →

AdresseTilgangHva den er
GET /api/v1Med nøkkel«Start her». Svarer med hvilken virksomhet nøkkelen løser til, hvilken rolle og hvilke rettigheter den har, og hele endepunktlista — lest rett av OpenAPI-dokumentet, så den kan verken liste noe som ikke finnes eller utelate noe som gjør det.
GET /api/v1/openapi.jsonMed nøkkelSelve kontrakten, som OpenAPI. Det er dette dokumentet klientgeneratorer, MCP-serveren og de to filene under alle leser.
GET /api/v1/skill.mdMed nøkkelBruksanvisningen i markdown, skrevet for en språkmodell: konvensjonene først, endepunktene etter, med verktøynavnet på hver. Den settes sammen av dokumentet framfor å skrives for hånd, så et nytt endepunkt kan ikke bli glemt.
GET /utviklere/openapi.jsonUten nøkkelDet samme OpenAPI-dokumentet som over, på denne nettsiden, for dere som vil lese kontrakten, generere en klient eller importere den i Postman før dere har en nøkkel. Det genereres av API-ets egne ruter, og en test går rød hvis det ikke er oppdatert.
GET /utviklere/provider-openapi.yamlUten nøkkelLeverandørflaten Flow Retail-kassen kaller midt i et salg, som OpenAPI. Den er en egen kontrakt, skrevet for kassen, og står ikke i dokumentet over.
GET /api/llms.txtUten nøkkelDet samme innholdet på en adresse som svarer før noen har fått en nøkkel. Hele poenget med URL-en er å kunne lese kontrakten på forhånd.

Skal en AI-agent bruke API-et, er MCP-serveren den korteste veien — oppsettet står på egen side.

Konvensjonene

Fire regler som gjelder overalt

De står i skill.md også, som det første en agent leser, fordi de er den delen det koster mest å oppdage ved et uhell.

Idempotency-Key

En mistet forbindelse skal ikke bli to ordrer

Send headeren på et skrivende kall. Nøkkelen er knyttet til virksomheten og lever i 24 timer; et gjentatt kall med den samme nøkkelen spiller av det lagrede svaret framfor å kjøre skrivingen på nytt. Det er forskjellen på et API du tør å prøve igjen mot og et du må rydde opp etter.

dry_run

Kjør skrivingen på ordentlig, og angre

Send «dry_run»: true i kroppen. Kallet gjør den ekte skrivingen inne i en transaksjon, returnerer hele resultatet, og ruller tilbake. Du får altså svaret du ville fått — ikke en simulering som kan ta feil. Kombiner den aldri med en ekte idempotensnøkkel du har tenkt å bruke etterpå.

expected_version

To som redigerer samtidig skal merke det

En PATCH tar feltet, lest av ressursens eget forrige svar. Stemmer det ikke, får du 409 — og hintet i feilen bærer versjonen du skal prøve på nytt med. Aldri en stille overskriving, som er den feilen ingen oppdager før noen spør hvor endringen deres ble av.

cursor

Nøkkelsett, aldri offset

Lister tar «limit» (1–200, standard 50) og «cursor». Send «cursor» tilbake nøyaktig slik forrige sides «next_cursor» kom — den er ugjennomsiktig med vilje. Er den null, var det siste side. Offset-paginering hopper over rader mens du blar gjennom et register som endrer seg, og et kunderegister gjør nettopp det.

Først prøvekjøringen — ingen idempotensnøkkel, ingenting lagret
curl -X POST https://friendsof.no/api/v1/segments \
  -H "Authorization: Bearer fo_live_DIN_NØKKEL" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Kjøpt Sandnes Garn, men ikke vært her på en stund",
        "is_dynamic": true,
        "dry_run": true,
        "rule_definition": {
          "match": "all",
          "rules": [
            { "field": "bought_brand", "op": "any_of",
              "value": ["sandnesgarn"], "window": { "days": 365 } },
            { "field": "last_order_at", "op": "not_within_days", "value": 90 }
          ]
        }
      }'
Så det ekte kallet — samme kropp uten dry_run, med idempotensnøkkel
curl -X POST https://friendsof.no/api/v1/segments \
  -H "Authorization: Bearer fo_live_DIN_NØKKEL" \
  -H "Idempotency-Key: segment-host-2026-uke-38" \
  -H "Content-Type: application/json" \
  -d '{ "name": "…", "is_dynamic": true, "rule_definition": { … } }'

De to er med vilje to kall: en prøvekjøring deltar aldri i idempotensen, så en nøkkel brukt på den lagrer ingenting å spille av etterpå. Regelsettet er det samme ordforrådet skjermbildet bruker, og GET /api/v1/segments/vocabulary (fo_get_segment_vocabulary) er kallet som lister feltene, operatorene og denne virksomhetens egne merker, varegrupper, butikker og byer — hent det først, så slipper du å gjette på verdier. Vil du bare vite hvor mange et regelsett treffer, koster POST /api/v1/segments/preview deg ingen skriving i det hele tatt.

Merker oppgis med merkets uid fra ordforrådet, ikke navnet — et navn treffer ingen. En kreditnota er ikke et kjøp: antall ordrer, snittkjøp og «sist kjøpt» teller bare kjøpene, mens «handlet for» trekker returene fra. Det som er kjøpt, er kjøpt — en retur tar ikke kunden ut av bought_brand eller bought_product. Vil du finne dem som har levert noe tilbake, bruker du returned_brand og returned_product, som også ser returer tatt på en vanlig kvittering i kassa.

Et segment kan gi medlemmene sine en tagg: send "tag_key": "interesse:garn" med segmentet, så får alle som er med taggen, og den tas av når de går ut eller segmentet arkiveres. Et dynamisk segment gir den aldri til en bedriftskunde. Én tagg har ett segment som bestemmer den — en nøkkel et annet segment bruker, gir 409 med segmentet i hint. En regel på taggen gjør segmentet som leser den avhengig av det som setter den, så et segment som filtrerer på sin egen tagg, avvises med 422.

Tagger på én kunde leses med GET /api/v1/profiles/{id}/tags, som sier hvor hver tagg kom fra — satt for hånd, av et segment, et skjema eller en import. Du setter tagger for hånd med POST /api/v1/profiles/{id}/tags og tar én av med DELETE /api/v1/profiles/{id}/tags/{key}. En tagg et segment setter, tas ikke av her: svaret er 409 med segmentet, fordi neste oppdatering ville satt den på igjen.

Det kunden har sett i nettbutikken, er to regler: viewed_page (en del av sideadressen) og viewed_brand (merkets uid, som i bought_brand). Begge krever days fra 1 til 90, fordi sidevisninger lagres i 90 dager, og tar min_count for «minst N ganger». Et segment med disse reglene og en tag_key gir kunder som besøker en kategori ofte, en tagg — og tar den av når de slutter. Regelen bought_from_email (first_order, any_order eller no_order) finner dem som ble kunde fra — eller har handlet fra — en e-post, slik kampanjeresultatene teller det.

Produktegenskaper — dimensjoner som fiber eller sesong, som virksomheten selv definerer — ligger under /api/v1/labels/products. Å opprette og endre krever en administratornøkkel, og POST /api/v1/labels/products/preview prøver et spørsmål på ti varer uten å lagre noe. Hver definisjon forteller dekning og en treffsikkerhet målt av mennesker: review-sample henter tilfeldige svar, og reviews registrerer om de var riktige. Regelen bought_label finner kundene som har kjøpt varer med en egenskap, med verdien fra ordforrådets labels (for eksempel «fiber:mohair»). GET /api/v1/labels/effect sammenligner kampanjer der målgruppen bruker egenskaper, med resten: mottakere, andel som handlet og omsetning per mottaker. Kundeegenskaper ligger under /api/v1/labels/profiles. Bryteren som slår dem på, finnes bare i grensesnittet — ingen nøkkel kan slå på vurdering av kunder.

Konvensjonen som sparer mest tid

Penger er en desimalstreng. Alltid.

Beløp krysser aldri nettverket som et rent JSON-tall. Internt er alt heltall i øre; på tråden er det "199.90" med valutaen ved siden av — nøyaktig to desimaler, og et minustegn foran når beløpet faktisk er negativt, slik en kreditnota er.

Det ser pedantisk ut til man har feilsøkt et avvik på noen øre gjennom fire systemer. Flyttall og penger hører ikke sammen, og et beløp som har vært innom et flyttall én gang er et beløp ingen kan bevise i ettertid.

Poeng er unntaket, og det er med vilje: de er heltall på tråden også, fordi de er talte enheter og ikke penger.

En side med ordrer
{
  "ok": true,
  "data": {
    "items": [
      {
        "id": "0d5f…",
        "order_number": "8842",
        "currency": "NOK",
        "total_gross": "1290.00",
        "total_net": "1032.00",
        "total_vat": "258.00"
      }
    ],
    "next_cursor": "eyJ0IjoiMjAyNi0wOS0xOFQ…"
  }
}
En feil
{
  "ok": false,
  "errors": [
    {
      "path": "segment_id",
      "code": "not_found",
      "message": "Segmentet finnes ikke.",
      "hint": "Hent gyldige segmenter fra GET /api/v1/segments."
    }
  ]
}

Hvert avvik sier hvilket felt det gjaldt, hva som var galt og — i hint — det korrigerte kallet, ikke en forklaring. Aldri «noe gikk galt»: den setningen koster en supporthenvendelse hver eneste gang.

Den ene regelen som ikke er en konvensjon

I V1

Ingenting sendes uten at et menneske har godkjent det

Hele kampanjeløpet er tilgjengelig over API-et: sette sammen, skrive utkast, rette innholdet, sende til godkjenning. Ingen av de stegene når en innboks.

Godkjenningen er det eneste som legger ekte e-post i sendekøen, og den gjøres av et menneske som er logget inn i FriendsOf — aldri med en API-nøkkel. Et kall til den gamle godkjenningsadressen får 410 approval_is_not_an_api_operation, uansett nøkkel. En nøkkel beviser ikke at noen har lest utkastet, og det er nettopp det en godkjenning skal bevise. Å stoppe en utsending går fortsatt med nøkkel.

Et utkast ingen gikk videre med, kan slettes med fo_delete_campaign (DELETE /api/v1/campaigns/{id}) så lenge det ikke er godkjent. Ingenting er sendt fra det, og revisjonsloggen beholder hva det var. Alt som er godkjent eller sendt, blir stående — og en kampanje som er avvist, tar ikke imot ny tekst (409 not_a_draft).

Et forslag som ikke kan lykkes, koster ingenting: har malen en plass for produkter som må fylles, og ingen av produktene i kallet finnes, svarer fo_compose_campaign med 400 layout_requires_products før AI-en blir spurt.

Kall altså fritt fram til fo_submit_campaign, og la et menneske ta det siste steget. Godkjenningskøen de ser er den samme lista, filtrert på awaiting_approval — og et utkast uten innhold er ikke med der, fordi det ikke er noe å godkjenne ennå.

Poeng

I V1

Poeng regnes ut hos oss, aldri i kassa

Kassa og nettbutikken sender det som skjedde — ordrer, returer, hvordan det ble betalt — og FriendsOf regner ut poengene etter butikkens egne regler, kampanjer og nivåer. Reglene endres uten at noe system må oppdateres.

En ordre uten medlem sendes likevel med e-post eller mobilnummer. Blir kunden medlem med det samme innen programmets frist (30 dager som standard, guest_earn_window_days), får hen poengene for ordren. Ingen trenger å logge inn eller være medlem før de handler.

Står programmet på pause, gir kjøp og handlinger fra den tiden aldri poeng, heller ikke etter at det er slått på igjen. Rapporteres en handling senere med et occurred_at inne i en pause, svarer API-et 409 program_paused.

Under alt ligger et sikkerhetsnett, i kroner og slått på som standard: et tak på saldo, en grense for én opptjening, en grense for bruk per dag og en høyeste kampanjemultiplikator. Poeng over en grense venter på at et menneske godkjenner dem (GET /api/v1/points/reviews, hver med hvilken grense som holdt dem og hva den var da, limit), og betaling over dagsgrensen avvises med daily_redemption_limit og hvor mye som er igjen i dag.

I kassekontrakten (provider-dokumentet) kan en retur av noe som ble betalt med poeng sendes i kroner, slik kassa kjenner den (amount på refusjonen, med den opprinnelige reservation_ref). FriendsOf regner om med det poengene var verdt da de ble brukt, og runder ned, så en retur gir aldri tilbake flere poeng enn kunden betalte med — heller ikke om butikken har endret poengverdien siden. Beløpet på en reservasjon vises alltid med den verdien den ble laget med.

Medlemslisten kan sorteres etter saldo, flest poeng først (sort: "POINTS" i profilsøket).

Hva gir denne handlekurven?
POST /api/v1/points/quote

{
  "member": { "email": "kari@example.no" },
  "lines": [
    { "line_ref": "1", "sku": "SANDNES-TYNN-MERINO", "line_total": "329.00" },
    { "line_ref": "2", "sku": "PINNE-4MM", "line_total": "166.00" }
  ]
}

→ { "points": 495, "value": "49.50", "available_after": 1450,
    "available_after_value": "145.00", "lines": [ … ] }

Anslaget bruker nøyaktig den samme beregningen som den ekte opptjeningen, og skriver ingenting. Alle kronebeløp kommer ferdig regnet ut: ingen integrasjon skal gange med en poengverdi selv. Et medlemsoppslag (GET /api/v1/points/members/lookup) gir saldoen i poeng og kroner, og betaling med poeng er to steg — hold av, trekk når salget er ferdig — med retur og tilbakebetaling på den samme reservasjonen.

For regnskapet: GET /api/v1/points/liability er hva kontoen skylder i poeng og kroner, hva som utløper snart og hva som har løpt ut i år, med månedstall som fryses den 1. GET /api/v1/points/balances er det samme per medlem, med totalen på hver side.

Andre kassesystemer

I V1

Tre nivåer, og hvert av dem virker uten det neste

  1. Opptjening. Send butikker, kunder og fullførte ordrer til importflaten (import:write). En kunde får bare det samtykket dere sender med, med sin egen dato — importen gir aldri samtykke av seg selv. Hvilken kanal hver av kassens egne samtykketyper betyr, settes med POST /api/v1/consent-map og fjernes igjen med DELETE /api/v1/consent-map; en type uten kartlegging leses verken som ja eller nei. Returer sendes som egne ordrer som peker på den opprinnelige linja, og tar tilbake nøyaktig det den ga. En ordre som kommer sent, tjener sent — ingenting går tapt.
  2. Vise poeng. Medlemsoppslag og anslag for handlekurven (points:read). Begge får feile: ved tidsavbrudd viser kassa ingenting og salget går videre.
  3. Betale med poeng. Hold av, trekk, gi tilbake (points:redeem). Dette er penger, og regelen er én: et salg fullføres aldri på poeng vi ikke har bekreftet.

Betales noe med poeng, sender kassa det med på ordren som en betalingslinje med koden LOYALTY_POINTS, så den delen ikke gir nye poeng. Ta kontakt, så får dere integrasjonsguiden med feltene, rekkefølgen og sjekklista før oppstart.

Leverandørflaten

I drift hos oss

Flaten Flow Retail-kassen kaller midt i et salg

Medlemsoppslag, saldo i poeng og kroner, anslaget kvitteringen skriver ut, og betaling med poeng: reservasjon, gjennomføring og tilbakeføring. Innmelding fra kassa og kupongbruk er beskrevet i kontrakten, men ikke bygget ennå.

Den er skrevet mot hvordan Flow Retail Core faktisk ser ut innvendig, ikke gjettet fra en spesifikasjon. Vår halvdel kjører; selve kassestøtten bygges på den andre siden, og vi setter ingen dato på en annens veikart. Selve poengene tjenes fra ordren slik den kommer til oss, aldri fra kassa, så en utskrift venter aldri på en skriving.

Reservere poeng i et salg
POST /api/v1/tenants/{tenantUid}/provider/members/{memberId}/points/reserve
Idempotency-Key: sale-8842-terminal-3

{
  "reservation_ref": "sale-8842-terminal-3",
  "amount": "50.00"
}

Kassa sier hvor mye som skal betales med poeng; svaret sier nøyaktig hva som ble holdt av og hva det er verdt i kroner, som desimalstreng, fordi det er vi som eier poengverdien og kassa som skal poste oppgjørslinja. reservation_ref er reservasjonens egen identitet: den samme referansen gir alltid den samme reservasjonen tilbake, også etter en tidsavbrutt omstart, så et nytt forsøk kan aldri bli et nytt hold på saldoen.

Nøkkelen bestemmer kontoen, ikke stien: en nøkkel som peker på en annen kontos Flow Retail-tenant får 403 TENANT_MISMATCH og leser ingenting.

Nettbutikken

I V1

Poeng på produktsiden, uten en linje kode til

Den samme taggen som sporer besøk og viser påmeldingsskjemaer, viser også hva et kjøp gir i poeng: under prisen på produktsiden, ved summen i handlekurven, og en medlemsfane nede i hjørnet. Tallet regnes ut med de samme reglene som poengene ordren faktisk gir, uten å vite hvem som ser på — nøkkelen i taggen er offentlig, så ingenting om besøkeren kommer tilbake til nettleseren. Elementene slås på i FriendsOf, ikke i butikken.

GET /api/v1/site-elements (forms:read) sier hvilke elementer som er på, om de faktisk vises på siden, og hva de har gitt de siste 30 dagene: visninger, klikk, nye medlemmer og ordrer etter visning, og hvor mange gjenkjente besøkende taggen har sett i de samme 30 dagene. Å slå et element på er ikke noe en nøkkel kan gjøre — det setter ord foran publikum, og det gjør en innlogget person.

Skal dere bygge mot oss?

Ta kontakt, så får dere OpenAPI-dokumentet og en nøkkel mot et testmiljø. Vi vil heller ha tilbakemeldinger på kontrakten nå enn etter at noen har bygget mot den.