openapi: 3.1.0 # ============================================================================= # Kossorna.se — externt API mot journalsystem (Vetmanager m.fl.) # # SANNINGSKÄLLA. Denna fil ska hållas i synk med koden. När router-scopet # /api/v1, någon controller under lib/cdb_web/controllers/api/, consent-schemat, # webhook.ex eller relaterade changesets ändras MÅSTE detta dokument uppdateras # i samma PR. Review-riggen (.claude/workflows/ai-review-rig.js) har en # `apidokumentation`-lins som tvingas aktiv när API-ytan rörs och flaggar drift. # # Statusmarkering: # x-status: live → finns i koden idag (verifierat mot lib/). # x-status: proposed → önskat av Vetmanager men FINNS EJ ännu. Bygg innan användning. # ============================================================================= info: title: Kossorna CDB API version: "1.4.0" summary: Synkront API + webhooks som externa journalsystem (Vetmanager) integrerar mot. description: | API för att koppla journalsystem mot kossornas CDB-data (nötkreatur från Jordbruksverkets centrala nötkreatursregister) samt hantera medlemmar och samtycken. ## Börja här `GET /api/v1` är en öppen meny som kräver varken token eller BusinessUuid. Den förklarar modellen, samtyckesregeln, leveranstakten och de vanligaste fallgroparna, och räknar upp endpointsen direkt ur routern. Läs den först - den här filen är kontraktet, menyn är kartan. Människoläsbar version: `GET /api`. **Förutsättning innan något av detta går att använda:** en API-nyckel kräver ett avtal om utlämnande av CDB-uppgifter mellan konsumenten och Jordbruksverket. Utan det avtalet får uppgifterna inte lämnas ut och ingen nyckel skapas. Avtalets bilaga 1 styr dessutom vilka fält kunden får se - det är den som `access_*`-flaggorna på `customers` speglar. ## Ägarskap idag vs. kvar att flytta (styr migreringsordningen) **Kossorna äger redan (exponerat i detta API):** - Onboarding av verksamhet (`POST /businesses`) och utlämning av `BusinessUuid`. - Medlemshantering med upsert på personnummer (`POST /members`, `find_by_ssn`). - Samtyckesinsamling i alla tre varianter (BankID / Freja / uppladdad fil) samt SMS-fallback. - Produktionsplats-/anläggningsuppslag (`/plants`). - Djurinnehav per medlem, samtyckesgatat (`/cdb/{ssn}/ppns_for_ssn`, `.../ppns_with_animals_for_ssn`). **Kossorna äger, men INTERNT (ingen extern yta — konsumenten rör det aldrig):** - Hela SJV-kedjan: beställning hos Jordbruksverket → XML-nedladdning (cert-baserad SJVComm) → parsning (Saxy) → DB. Trigga/fråga en SJV-beställning går **inte** via API:t; det sker schemalagt/admin (`order_scheduler.ex` beställer 20:30 Europe/Stockholm dagligen i produktion). Externa system läser bara den *resulterande* plats-/djurdatan via `/plants` och `/cdb`. Enda insynen i kedjan är `GET /api/v1/last_file_date`, som säger **när** filerna senast blev korrekt inlästa - inget om beställningen, och ingenting går att trigga därifrån. **Vetmanager gör fortfarande själv (inte flyttat hit):** - All klinisk journalföring. Detta API täcker bara medlems-, samtyckes- och CDB-data. ## Gap mot Vetmanagers kravlista (markerade `x-status: proposed` nedan) Följande efterfrågades men finns **inte** i koden idag — bygg dem innan de utlovas: - Webhook-signatur (HMAC) för äkthetsverifiering. Utgående webhook saknar helt signering. - Distinkta webhook-events för *samtycke återkallat*, *CDB-data tillgänglig*, *produktionsplats ändrad*. Idag finns bara EN webhook på tråden (member-pingen); den triggas internt av flera events (medlem skapad/uppdaterad, samtycke godkänt, SMS-status) men inget event-namn skickas till mottagaren (se `webhooks`-sektionen). - Per-business registrering av mottagar-URL. Idag lagras `webhook_url` per **kund** (customer), inte per business, och sätts internt — det finns ingen self-service-endpoint för det. - Idempotensnyckel/leverans-id i webhookleveransen. Ingen skickas till mottagaren idag. - Stabil `code` på felsvaren från `/members`, `/businesses` och `/plants`. Auth-plugarna och `/cdb` har det (se `ErrorMessage`), men de övriga controllerna returnerar fortfarande bara `{"error": "..."}` i blandad svenska/engelska, och changeset-fel i ett annat kuvert (`ValidationErrors`). Felkuvertet är alltså kodat till hälften. ## Felmodell och retry (viktigt — Vetmanager grenar på detta) Se `components/schemas` (`ErrorMessage`, `ValidationErrors`, `FrameworkError`) och `components/responses`. Kort: - **4xx (400/401/404/422)** = klientfel, **retrya inte** — samma anrop ger samma svar. 401 är överlagrat och betyder både "ogiltig token/BusinessUuid" och "saknad koppling/inget aktivt samtycke". Gren på `code` i kroppen, inte på statuskoden: `invalid_token`, `missing_business`, `no_customer_business_link`, `no_business_member_link`, `no_consent`. Se `components/responses/Unauthorized`. Retry hjälper inte i något fall — men `no_consent` kan lösa sig av sig självt när medlemmen signerar. - **5xx / nätverkstimeout** = enda rimligt retrybara klassen. Ingen `Retry-After` skickas idag. - Felkuvertet är i dagsläget **inte enhetligt**: manuella fel = `{"error": "..."}`, changeset-fel = `{"errors": {"": ["..."]}}`, oväntade fel = `{"errors": {"detail": "..."}}`. ## Idempotens / upsert `POST /members` är idempotent på `ssn` inom en business: finns medlemmen redan uppdateras den (HTTP 200) i stället för att dubbletteras (HTTP 201 vid nyskapande). `POST /businesses` är på motsvarande sätt upsert på org-`ssn` per kund (201 nytt, 200 ny koppling, 409 om kopplingen redan finns). contact: name: Kossorna url: https://kossorna.se servers: - url: https://kossorna.se description: Produktion - url: http://localhost:4000 description: Lokal utveckling (dev/test-host bör bekräftas med drift innan den delas ut) # ============================================================================= # Säkerhet # ============================================================================= components: securitySchemes: CustomerToken: type: apiKey in: header name: Authorization description: | Kundens API-nyckel. Skickas som `Authorization: Bearer ` eller bara `` (ledande `Bearer ` strippas serversidan). Nyckeln matchas mot `customers.api_key`. Saknad/ogiltig → 401 `{"error":"Unauthorized"}`. BusinessUuid: type: apiKey in: header name: BusinessUuid description: | Identifierar vilken verksamhet anropet gäller. Värdet är `BusinessCustomer.uuid` som returneras av `POST /businesses`. Krävs på alla `/members`- och `/cdb`-endpoints (utöver `Authorization`). Saknad/ogiltig → 401 `{"error":"Unauthorized"}`. Headern läses skiftlägesokänsligt (`businessuuid`). # =========================================================================== # Scheman # =========================================================================== schemas: # ---- Samtycke (KANONISKT — det Vetmanager rättar in sin payload efter) ---- ConsentInput: type: object description: | Skickas som `member.current_consent` vid `POST /members` när samtycke redan är inhämtat. Utelämnas det skapas ett osignerat samtycke och en samtyckes-SMS skickas i stället. Tre varianter (BankID / Freja / uppladdad fil). Servern validerar i dagsläget INTE att exakt en variant är ifylld — varianten härleds vid läsning av vilket markörfält som är satt (`bankid_order_ref`, `freja_sign_ref` eller `file_url`/`file`). Skicka en komplett variant. oneOf: - $ref: '#/components/schemas/BankIDConsent' - $ref: '#/components/schemas/FrejaConsent' - $ref: '#/components/schemas/FileConsent' properties: sign_at: type: string format: date-time description: Tidpunkt för signering (fältet heter `sign_at`, inte `signed_at`). signed_ssn: type: string description: Personnummer hämtat ur signaturen. signed_text: type: string description: Den avtalstext som signerades. signature: type: string description: Signaturen (BankID-signatur, eller full JWS för Freja). externalDocs: description: | LIVSCYKEL — markörfälten betyder olika saker beroende på vem som samlar in. Skickas samtycket hit av kundsystemet (from_customer) är markörfältet satt direkt och samtycket räknas som aktivt från den sekunden. Samlar kossorna.se in samtycket själv (SMS-länk + BankID/Freja) sätts markörfältet FÖRST vid godkännande — under en pågående signering ligger referensen i ett temp-fält och samtycket är INTE aktivt. `sign_at` är inte markören och avgör ingenting. url: https://kossorna.se/ BankIDConsent: type: object title: BankID-samtycke required: [bankid_order_ref] properties: bankid_order_ref: type: string description: BankID orderReferens (markörfält för BankID-varianten). signature: { type: string } ocsp_response64: type: string description: BankID OCSP-svar, base64 (fältet heter `ocsp_response64`). signed_text: { type: string } signed_ssn: { type: string } sign_at: { type: string, format: date-time } FrejaConsent: type: object title: Freja-samtycke required: [freja_sign_ref] properties: freja_sign_ref: type: string description: | Freja signeringsreferens (markörfält för Freja-varianten). Sätts vid initiering för samtycken som kommer in via detta API, men först vid GODKÄNNANDE för samtycken kossorna.se samlar in själv — en pågående signering håller referensen i ett internt temp-fält och räknas inte som ett aktivt samtycke. signature: type: string description: Full JWS från Freja. signed_text: { type: string } signed_ssn: { type: string } sign_at: { type: string, format: date-time } FileConsent: type: object title: Uppladdat samtyckesdokument required: [file, file_type] properties: file: type: string format: byte description: >- Dokumentet som base64-kodad sträng (INTE multipart). Laddas upp till S3. Hela request-kroppen får vara högst 32 MB - base64 lägger på ca 33 %, så filen bör hålla sig under ~23 MB. file_type: type: string enum: [pdf, jpg, jpeg, png] description: Filtyp; styr content-type vid S3-uppladdning. ConsentSmsStatus: type: object nullable: true description: Status för utskickad samtyckes-SMS. `null` om ingen SMS skickats. properties: send_status: type: string description: T.ex. "ACCEPTED", "NOT SENT" eller sträng som innehåller "ERROR". sent_at: { type: string, format: date-time } to_number: { type: string } fee: type: number description: Avgift (decimal). ~0.98 kr per 160 tecken. CurrentConsent: type: object nullable: true description: | Det aktiva samtycket: vem som skrev under och när. `null` när verksamheten saknar aktivt samtycke för medlemmen (aldrig inhämtat, återkallat, eller en signering som påbörjats men inte godkänts). Signeraren är inte alltid medlemmen. En gård kan ha medägare i `extra_ssns`, och bakom ett organisationsnummer står en firmatecknare - namnet kommer ur signaturen, inte ur medlemsraden. Signerarens personnummer ingår inte. properties: signed_by: type: string nullable: true description: | Namnet ur BankID- eller Freja-signaturen. `null` för samtycken som levererats via `current_consent` utan `signed_ssn`/namn, och för uppladdade dokument. signed_at: type: string format: date-time nullable: true description: Tidpunkt för signeringen (UTC). `null` om den inte är känd. method: type: string enum: [bankid, freja, document] description: Härleds ur vilket markörfält samtycket bär. # ---- Medlem ---- MemberInput: type: object required: [member] properties: member: type: object required: [ssn, name] properties: ssn: type: string description: Personnummer (12 siffror, modul-10-validerat). Upsert-nyckel. name: { type: string } phone: type: string description: >- Krävs endast när `current_consent` utelämnas för en ny medlem - då skickas samtyckesbegäran som SMS, och utan nummer avvisas anropet med 422 ("phone is required when no consent is provided"). Följer `current_consent` med skickas inget SMS och numret får utelämnas. Ett nummer som finns men inte går att SMS:a ger 422 ("phone number not available for SMS") - medlemmen skapas ändå. extra_ssns: type: array maxItems: 4 items: { type: string } description: Upp till 4 extra personnummer (fördelas till ssn1..ssn4, var för sig modul-10-validerade). current_consent: $ref: '#/components/schemas/ConsentInput' MemberResponse: type: object properties: member: type: object properties: name: { type: string } ssn: { type: string } phone: { type: string } consent: type: boolean description: Om verksamheten har (satt) samtycke för medlemmen. consent_sms_status: $ref: '#/components/schemas/ConsentSmsStatus' current_consent: $ref: '#/components/schemas/CurrentConsent' sjv_status: $ref: '#/components/schemas/SjvStatus' extra_ssns: type: array items: { type: string } description: Finns i svaret endast om något av ssn1..ssn4 är satt. SjvStatus: type: object nullable: true description: >- Jordbruksverkets invändning mot medlemmens person-/organisationsnummer, eller `null` när det inte finns någon. Bara numren på din egen medlemsrad bedöms (medlemmens `ssn` samt `extra_ssns`). Fältet finns för att en tom djurlista ser exakt likadan ut oavsett orsak - utan det går "gården har inga djur" inte att skilja från "numret ger ingen data". properties: code: type: string enum: [orgnr_invalid, orgnr_not_found] description: >- `orgnr_invalid` - SJV godtar inte numret (fel format eller kontrollsiffra). Det kommer inte med i någon beställning förrän en människa rättat det, så här krävs åtgärd av dig. `orgnr_not_found` - numret är korrekt uppbyggt men okänt i CDB. Kan bli känt senare (nyregistrering, ägarbyte) och frågas automatiskt igen. **Presentera det aldrig som ett skrivfel för användaren** - det är också svaret för skyddade personuppgifter. Har medlemmen flera nummer väger `orgnr_invalid` tyngst. orgnr: type: array items: { type: string } description: De av medlemmens nummer som invändningen gäller. since: type: string format: date nullable: true description: Första dagen SJV klagade på numret. Skiljer ett färskt fall från ett som legat länge. message: type: string description: Färdig text att visa. Formuleras neutralt för `orgnr_not_found`. MemberListItem: type: object description: Kompakt form som används av listnings-endpointen (skiljer sig från show). properties: name: { type: string } ssn: { type: string } ok: { type: boolean } # ---- Business ---- BusinessInput: type: object required: [business] properties: business: type: object required: [name, ssn, town, email] properties: name: { type: string } ssn: type: string description: Organisationsnummer (modul-10-validerat). Uppdateras ej vid PATCH. town: { type: string } email: { type: string, format: email } affiliates: { type: string } BusinessResponse: type: object properties: business: type: object properties: name: { type: string } ssn: { type: string } town: { type: string } email: { type: string } affiliates: { type: string } uuid: type: string description: BusinessCustomer.uuid — skicka detta som `BusinessUuid`-header i efterföljande anrop. BusinessListItem: type: object properties: ssn: { type: string } name: { type: string } uuid: { type: string } # ---- Plant / produktionsplats ---- Plant: type: object description: | `id`, `ppn` och `adress` lämnas alltid ut. **Alla övriga fält nedan är opt-in per kund** och styrs av `access_*`-flaggorna på kunden, som speglar bilaga 1 i kundens SJV-avtal. Ett fält som kunden inte har åtkomst till utelämnas ur svaret — det returneras alltså inte som `null`. Räkna aldrig med att en nyckel finns; kontrollera att den är närvarande. Uppgifterna kommer från SJV i tre uppgiftsnivåer. Nivå 3 innehåller bara statuskod, prefix, PPN, platsstatuskod, adress och djurhållare. Koordinater, läns-/kommun-/församlingsuppgifter, fastighetsbeteckning, telefonnummer och djurslag finns först på nivå 1 och 2. En kund vars avtal ger nivå 3 får därför `null` i de fälten även med flaggan påslagen. properties: id: { type: integer } ppn: type: string description: Produktionsplatsnummer (anläggning). Kan utelämnas för vissa kunder (kundspecifik projektion). adress: type: string description: | **Anläggningens** besöksadress, inte djurhållarens. En djurhållare med flera anläggningar har samma adress på sig själv men olika på platserna. koordinater: type: object nullable: true description: SWEREF 99 TM. Decimaltal med komma som decimaltecken, som SJV levererar dem. Kräver `access_xy`. properties: x: { type: string, example: '645270,258' } y: { type: string, example: '7042090,314' } lanskod: type: string description: Länskod. Sträng, inte heltal — inledande nolla är signifikant. Kräver `access_lanskod`. example: '22' lan: type: string description: Länets namn. Kräver `access_lan`. example: VÄSTERNORRLANDS LÄN kommunkod: type: string description: Kommunkod inom länet. Sträng, inledande nolla är signifikant. Kräver `access_kommunkod`. example: '84' kommun: type: string description: Kommunens namn. Kräver `access_kommun`. example: ÖRNSKÖLDSVIK forsamlingskod: type: string description: Församlingskod. Sträng, inledande nolla är signifikant. Kräver `access_forsamlingskod`. example: '04' forsamling: type: string description: Församlingens namn. Kräver `access_forsamling`. example: ANUNDSJÖ postnummer: type: string description: Anläggningens postnummer. Kräver `access_postnummer`. postadress: type: string description: Anläggningens postadress. Kräver `access_postadress`. fastighetsbeteckning: type: string description: Kräver `access_fastighetsbeteckning`. example: ANUNDSJÖ-SÖRFLÄRKE 6:18 telefonnummer: type: string description: Telefonnummer till fastigheten, inte till djurhållaren. Kräver `access_telefonnummer`. djurhallare_namn: type: string description: Kräver `access_djurhallare_namn`. djurhallare_ssn: type: string description: Djurhållarens person- eller organisationsnummer. Kräver `access_djurhallare_ssn`. djurhallare_addid: type: string description: Kräver `access_djurhallare_addid`. djurhallare_lan: type: string description: Kräver `access_djurhallare_lan`. djurhallare_kommunkod: type: string description: Kräver `access_djurhallare_kommunkod`. djurhallare_forsamlingskod: type: string description: Kräver `access_djurhallare_forsamlingskod`. djurhallare_adress: type: string description: | **Djurhållarens** adress, inte anläggningens. Kräver `access_djurhallare_adress`. djurhallare_co_adress: type: string description: Kräver `access_djurhallare_co_adress`. djurhallare_postnummer: type: string description: Kräver `access_djurhallare_postnummer`. djurhallare_postadress: type: string description: Kräver `access_djurhallare_postadress`. djurhallare_telefon: type: string description: Kräver `access_djurhallare_telefon`. djurhallare_mobil: type: string description: Kräver `access_djurhallare_mobil`. djurhallare_email: type: string description: Kräver `access_djurhallare_email`. djurslag: type: array description: | Djurslag som finns, eller har funnits, på anläggningen. Kräver `access_djurslag`. Listan är historisk: samma `djurslagstyp` kan förekomma flera gånger med olika perioder, och den utan `till` är den aktuella. Tom lista betyder att SJV inte levererat några djurslagsuppgifter (t.ex. på uppgiftsnivå 3) — inte att åtkomst saknas. items: { $ref: '#/components/schemas/PlantDjurslag' } PlantDjurslag: type: object properties: djurslagstyp: type: string description: SJV:s kod för djurslaget. example: NOT beskrivning: type: string description: Klartextbeskrivning av djurslaget. example: Nötkreatur fran: { type: string, format: date } till: type: string format: date nullable: true description: Saknas eller är null för den aktuella perioden. antal: type: integer nullable: true description: | Antal djur. SJV anger det bara för vissa djurslagstyper (FÅR, GET, NÖT). Kräver `access_djurslag_antal`. rakningsdatum: type: string format: date nullable: true description: | Datum då antalsuppgiften inhämtades. Anges bara för vissa djurslagstyper (FÅR, GET). Kräver `access_djurslag_rakningsdatum`. maxkapacitet: type: string nullable: true description: Maxkapacitet för djurslagstypen. Sträng, inte heltal — så levererar SJV den. Kräver `access_djurslag_maxkapacitet`. example: '150' produktionsinriktningar: type: array description: Verksamhet på platsen för djurslaget. Kräver `access_produktionsinriktning`. items: { $ref: '#/components/schemas/PlantProduktionsinriktning' } PlantProduktionsinriktning: type: object properties: typkod: type: string example: AVEL beskrivning: type: string example: Avelsbesättning fran: { type: string, format: date } till: type: string format: date nullable: true planerat_antal_djur: type: integer nullable: true description: Antal djur som avses att hållas. Kräver `access_produktionsinriktning_planerat_antal`. # ---- CDB / djurinnehav ---- PpnHolding: type: object properties: ppn: type: integer description: Produktionsplatsnummer. Heltal i svaret, inte sträng. djurhallare_namn: type: string description: Djurhållarens namn. adress: type: string description: | **Anläggningens** adress, inte djurhållarens. En djurhållare med flera anläggningar har samma adress på sig själv men olika på platserna. postnummer: { type: string } postadress: { type: string } animal_count: type: integer description: | Antal **levande** djur som står på anläggningen just nu - exakt samma urval som `current` i ppns_with_animals_for_ssn. Döda djur räknas inte, inte heller de som ligger kvar i `dead` de första 40 dagarna. AnimalRef: type: object description: Ett djur i en av anläggningens listor. properties: uuid: type: string description: | Djurets stabila nyckel. Identiteten kan bytas under djurets liv, uuid:t ändras aldrig - spara det i stället för identitetssträngen. Null för djur som importerades innan kolumnen fanns och ännu inte backfyllts (`mix animals.backfill_uuid`). id: type: string description: 'Format: PREFIX-PPN(6)-INDIVIDNUMMER(4)-KONTROLLSIFFRA.' DeadAnimalRef: allOf: - $ref: '#/components/schemas/AnimalRef' - type: object properties: avliden_datum: type: string format: date description: Dödsdatum enligt SJV (`avlidenDatum`). DepartedAnimalRef: allOf: - $ref: '#/components/schemas/AnimalRef' - type: object properties: avgick_datum: type: string format: date description: | Dagen djurets plats-period på anläggningen stängdes - flyttat till annat PPN, eller borttaget ur SJV:s leverans (statuskod BORTTAGEN). DeadRegisterEntry: allOf: - $ref: '#/components/schemas/AnimalRef' - type: object properties: avliden_datum: type: string format: date description: När djuret dog enligt SJV. Kan ligga långt bak i tiden. registrerad_datum: type: string format: date description: | När **vi** bokförde dödsfallet. Det är den här dagen fönstret (`from_days`/`to_days`) räknas på, inte `avliden_datum`. DepartedRegisterEntry: allOf: - $ref: '#/components/schemas/AnimalRef' - type: object properties: avgick_datum: type: string format: date description: Sista dagen djuret stod på någon av samtyckets anläggningar. registrerad_datum: type: string format: date description: När **vi** bokförde avgången. Fönstret räknas på den här dagen. PpnWithAnimals: type: object properties: ppns: type: object properties: ppn: type: integer description: Produktionsplatsnummer. Heltal i svaret, inte sträng. adress: type: string description: '**Anläggningens** adress + postadress, inte djurhållarens.' current: type: array description: Levande djur som står på anläggningen nu. items: { $ref: '#/components/schemas/AnimalRef' } dead: type: array description: | Djur som dött de senaste 40 dagarna medan de stod på anläggningen. Efter 40 dagar försvinner de ur svaret. items: { $ref: '#/components/schemas/DeadAnimalRef' } departed: type: array description: | Djur som lämnat anläggningen de senaste 40 dagarna utan att dö här - flyttade till annat PPN, eller borttagna ur SJV:s leverans. Efter 40 dagar försvinner de ur svaret. items: { $ref: '#/components/schemas/DepartedAnimalRef' } animals: type: array deprecated: true description: | **Utgående.** Identiskt innehåll med `current`, kvar för de kunder som redan läser fältet. Nya integrationer ska läsa `current`/`dead`/`departed`. OBS att innehållet ändrats: fältet innehöll tidigare även djur som var döda sedan länge (se `dead` nedan om varför). items: { $ref: '#/components/schemas/AnimalRef' } AnimalDetails: type: object properties: uuid: type: string description: Stabil nyckel. Överlever identitetsbyten - spara den här. identitet: type: string description: Aktuell identitet, PREFIX-PPN(6)-INDIVIDNUMMER(4)-KONTROLLSIFFRA. tidigare_identiteter: type: array description: Identiteter djuret haft tidigare, nyast först. items: { type: string } fodelsedatum: { type: string, format: date, nullable: true } kon: type: integer description: SJV:s könskod. nullable: true raskod: { type: integer, nullable: true } ras: type: string nullable: true description: Rasens namn, null om raskoden är okänd för oss. djurslagstyp: { type: string, nullable: true } statuskod: type: string description: 'NY, ANDRAD eller BORTTAGEN.' modersidentitet: { type: string, nullable: true } avliden_datum: { type: string, format: date, nullable: true } forsta_kalvningsdatum: { type: string, format: date, nullable: true } senaste_kalvningsdatum: { type: string, format: date, nullable: true } antal_kalvningar: { type: integer, nullable: true } antal_dodfodda_kalvar: { type: integer, nullable: true } aktuellt_ppn: { type: integer, nullable: true } platsperioder: type: array description: Anläggningshistorik, äldst först. Perioden utan `till` är den aktuella. items: type: object properties: ppn: { type: integer } fran: { type: string, format: date } till: { type: string, format: date, nullable: true } uppdaterad_av_fil: type: string format: date nullable: true description: Vilket SJV-dygn uppgiften senast kom med. Delivery: type: object nullable: true description: | En inläst leverans. `null` betyder att ingen leverans av den typen är inläst - inte att den misslyckades. properties: file_date: type: string format: date description: | Beställningens dag, alltså vilket läge datan speglar. Beställningen går kvällen före; filerna hämtas morgonen därpå. examples: ['2026-09-17'] imported_at: type: string format: date-time description: När inläsningen blev klar (UTC). examples: ['2026-09-18T05:22:13Z'] LastFileDate: type: object description: | Datafärskhet. Se `GET /api/v1/last_file_date` för vad "korrekt inläst" betyder och varför de två leveranstyperna redovisas var för sig. required: [last_file_date, imported_at, total, incremental, import_running] properties: last_file_date: type: [string, 'null'] format: date description: | Den senaste av `total.file_date` och `incremental.file_date`. Räcker för den som bara vill visa "data per ". Den som behöver veta om en **viss** medlem är uppdaterad måste titta på rätt typ - se `incremental`. examples: ['2026-09-17'] imported_at: type: [string, 'null'] format: date-time description: När den leveransen blev färdiginläst (UTC). examples: ['2026-09-18T05:22:13Z'] total: $ref: '#/components/schemas/Delivery' incremental: $ref: '#/components/schemas/Delivery' import_running: type: boolean description: | En inläsning är påbörjad men inte klar. Ett `true` här förklarar varför datumet ännu inte flyttat fram - vänta hellre än larma. Bara inläsningar startade de senaste sex timmarna räknas: en process som dött mitt i lämnar sin rad öppen för alltid, och utan fönstret hade den raden svarat "pågår" i evighet. # ---- Felmodeller ---- ErrorMessage: type: object description: | Manuella controller-fel. `code` är det stabila fältet att gren på; `error` är fri text och kan skrivas om utan kontraktsändring. Fältet är **additivt**: ingen befintlig `error`-text har ändrats, så ett journalsystem som grenar på texten fungerar precis som förut. `code` finns på autentiseringsfelen (båda plugarna) och på alla fel från `/cdb`-endpointsen. Övriga endpoints (`/members`, `/businesses`, `/plants`) returnerar tills vidare bara `error` - se `x-status: proposed` i beskrivningen av felmodellen. properties: code: type: string description: Stabil felkod. Saknas på endpoints som ännu inte kodats. enum: - invalid_token - missing_business - no_customer_business_link - no_business_member_link - no_consent - invalid_ssn - member_not_found - animal_not_found - invalid_range error: { type: string } ValidationErrors: type: object description: Changeset-valideringsfel (fält → meddelanden). properties: errors: type: object additionalProperties: type: array items: { type: string } FrameworkError: type: object description: Phoenix-genererade fel (oväntat/404 via ErrorJSON). properties: errors: type: object properties: detail: { type: string } # =========================================================================== # Återanvändbara svar # =========================================================================== responses: Unauthorized: description: | Retrya ej. Fem olika saker ger 401 - `code` skiljer dem åt: | code | betyder | |---|---| | `invalid_token` | `Authorization`-headern saknas eller matchar ingen kund | | `missing_business` | token dög, men `BusinessUuid` saknas eller är okänd för kunden | | `no_customer_business_link` | ingen koppling kund ↔ verksamhet | | `no_business_member_link` | ingen koppling verksamhet ↔ medlem | | `no_consent` | **medlemmen har inget aktivt samtycke** (saknas, återkallat eller inte signerat än) | `no_consent` är det enda som kan lösa sig av sig självt - medlemmen kan signera i efterhand. De övriga kräver en ändring hos kund eller hos oss. content: application/json: schema: oneOf: - $ref: '#/components/schemas/ErrorMessage' - $ref: '#/components/schemas/FrameworkError' NotFound: description: | Resursen finns inte. Retrya ej. Koder på `/cdb`: `member_not_found` (personnumret hör inte till någon medlem) och `animal_not_found` (djuret finns inte, eller ligger inte i någon av anläggningens tre listor). content: application/json: schema: oneOf: - $ref: '#/components/schemas/ErrorMessage' - $ref: '#/components/schemas/FrameworkError' Unprocessable: description: | Valideringsfel (t.ex. ogiltigt ssn, saknat fält, ogiltigt SMS-nummer). Retrya ej. Koder på `/cdb`: `invalid_ssn`, och `invalid_range` för felaktiga `from_days`/`to_days` på registerhållnings-endpointsen. content: application/json: schema: oneOf: - $ref: '#/components/schemas/ErrorMessage' - $ref: '#/components/schemas/ValidationErrors' ServerError: description: Oväntat fel. Enda rimligt retrybara klassen (tillsammans med nätverkstimeout). content: application/json: schema: $ref: '#/components/schemas/FrameworkError' security: - CustomerToken: [] # ============================================================================= # Endpoints # ============================================================================= paths: /api/v1: get: operationId: apiMenu summary: API-meny - öppen karta över modellen och endpointsen x-status: live security: [] description: | Öppen ingång utan autentisering. Beskriver hierarkin (kund → verksamhet → medlem → anläggning → djur), vilka headers som krävs, hur samtycket styr åtkomsten, hur data levereras från Jordbruksverket, felkoderna, och de fallgropar som biter hårdast vid en integration. Endpointlistan i svaret **genereras ur routern**, så den kan inte drifta från den faktiska ytan. Varje post bär `status`: `live` eller `ej_implementerad` (routes som `resources` genererat men som controllern saknar - de svarar 500). Menyn duplicerar inte den här filen. Fältnivån - scheman, felkuvert, statuskoder - står här, och den här filen vinner vid konflikt. Samma innehåll finns människoläsbart på `GET /api` - en öppen sida som renderar exakt den här datan. responses: '200': description: Menyn content: application/json: schema: type: object required: [tjanst, version, endpoints] properties: tjanst: { type: string, examples: ['Kossorna CDB-API'] } version: { type: string, examples: ['v1'] } syfte: { type: string } kontrakt: type: object description: Var det fullständiga kontraktet finns, och basadresserna. kom_igang: type: array description: | Stegen från nyckel till djurdata, i ordning. Varje steg låser upp nästa. items: type: object properties: steg: { type: integer } rubrik: { type: string } vad: { type: string } anrop: { type: [string, 'null'] } autentisering: type: object description: Headers, vad som går utan token, och ordningen token → BusinessUuid → samtycke. modell: type: object description: Hierarkin och begreppen (anläggning/PPN, aktuellt_ppn, uuid mot öronnummer). datakalla: type: object description: Ursprung, takt, första leveransen och eftersläpning. samtycke: type: object description: Regeln, de tre varianterna, SMS-fallbacken och vem som får signera. webhook: type: object description: Pingens form, vad som triggar den, retrytrappan och avsaknaden av signatur. fel: type: object description: | Regeln att grena på `code` i stället för statuskod, plus `koder` - en **lista**, inte ett objekt: ordningen är den som ett anrop faller igenom kontrollerna i, och den är avsiktlig. properties: koder: type: array items: type: object properties: kod: { type: string, examples: ['no_consent'] } betyder: { type: string } fallgropar: type: array description: Kända fällor vid integration, var och en med skäl. items: type: object properties: sak: { type: string } varfor: { type: string } endpoints: type: array description: Genererad ur routern. items: type: object properties: metod: { type: string, examples: ['GET'] } sokvag: { type: string, examples: ['/api/v1/cdb/{ssn}/dead'] } omrade: { type: string, examples: ['djurdata'] } status: { type: string, enum: [live, ej_implementerad, odokumenterad] } kraver: type: array items: { type: string } examples: [['Authorization', 'BusinessUuid']] beskrivning: { type: string } /api/v1/businesses: post: operationId: createBusiness summary: Onboarda verksamhet och få BusinessUuid description: | Skapar (eller kopplar) en verksamhet till den anropande kunden och returnerar `uuid` som ska användas som `BusinessUuid`-header i efterföljande medlems-/cdb-anrop. Upsert på org-ssn: 201 nytt, 200 ny koppling till befintlig verksamhet, 409 om kopplingen redan finns. x-status: live security: - CustomerToken: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/BusinessInput' } responses: '201': { description: Ny verksamhet skapad, content: { application/json: { schema: { $ref: '#/components/schemas/BusinessResponse' } } } } '200': { description: Befintlig verksamhet, ny kundkoppling, content: { application/json: { schema: { $ref: '#/components/schemas/BusinessResponse' } } } } '409': { description: Kopplingen finns redan (returnerar befintlig), content: { application/json: { schema: { $ref: '#/components/schemas/BusinessResponse' } } } } '422': { $ref: '#/components/responses/Unprocessable' } '401': { $ref: '#/components/responses/Unauthorized' } get: operationId: listBusinesses summary: Lista verksamheter kopplade till kunden x-status: live security: - CustomerToken: [] responses: '200': description: Lista content: application/json: schema: type: array items: { $ref: '#/components/schemas/BusinessListItem' } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/businesses/{uuid}: parameters: - name: uuid in: path required: true schema: { type: string } description: >- BusinessCustomer.uuid — samma värde som `POST /businesses` returnerar i `business.uuid` och som skickas som `BusinessUuid`-header. Uppslaget är scopat till den autentiserade kunden; annan kunds uuid ger 404. get: operationId: showBusiness summary: Hämta en verksamhet x-status: live security: - CustomerToken: [] responses: '200': { description: Verksamhet, content: { application/json: { schema: { $ref: '#/components/schemas/BusinessResponse' } } } } '404': { $ref: '#/components/responses/NotFound' } '401': { $ref: '#/components/responses/Unauthorized' } patch: operationId: updateBusiness summary: Uppdatera verksamhet (name, town, email, affiliates — ej ssn) x-status: live security: - CustomerToken: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/BusinessInput' } responses: '200': { description: Uppdaterad, content: { application/json: { schema: { $ref: '#/components/schemas/BusinessResponse' } } } } '422': { $ref: '#/components/responses/Unprocessable' } '404': { $ref: '#/components/responses/NotFound' } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/last_file_date: get: operationId: lastFileDate summary: När Jordbruksverkets filer senast blev korrekt inlästa x-status: live security: - CustomerToken: [] description: | Datafärskhet. Svarar på den enda fråga ett anslutande system kan ställa om vår leveranskedja: **hur gammal är datan jag just hämtade?** Utan den går det inte att skilja "inget har hänt hos djurhållaren" från "dagens filer kom aldrig in" - i djurdatan ser de två likadana ut, och bara det ena är ett skäl att larma. **Kräver bara `Authorization`, inte `BusinessUuid`.** Ingen medlemsdata passerar här; svaret är detsamma för alla verksamheter. ## Vad "korrekt inläst" betyder Två villkor, båda uppfyllda: 1. Varje fil leveransen innehöll är både **hämtad och tolkad**. En fil som kom hem men inte gick att parsa räknas inte. 2. Leveransen innehöll en **djurindividfil**. En beställning som Jordbruksverket avvisar kommer tillbaka som enbart en svarsfil, och den hämtas och tolkas problemfritt - men bär ingen djurdata. Sådana dygn rapporteras inte som färska. ## `total` och `incremental` redovisas var för sig De två dagliga leveranserna täcker **disjunkta uppsättningar djurhållare**, inte två detaljnivåer: - `incremental` - förändringarna för djurhållare som redan fått en fullständig leverans. Det är den som gäller för de allra flesta medlemmar varje dygn. - `total` - hela beståndet för dem som aldrig fått en fullständig leverans, plus de äldsta i rotationen. Ett hopslaget datum hade dolt skillnaden, därför finns båda. `last_file_date` på toppnivå är helt enkelt den senaste av de två och räcker för den som bara vill visa "data per ". ## Datumet är beställningens dag Beställningen skickas kvällen före och filerna hämtas morgonen därpå. `file_date` är alltså vilket läge datan speglar, inte när vi hämtade den. `imported_at` är när vi blev klara med inläsningen (UTC). ## Null är ett giltigt svar Samtliga datumfält kan vara `null` - det betyder "ingen sådan leverans är inläst än", inte att något gått sönder. En nyuppsatt miljö ser precis så ut. Ett anrop per dygn räcker gott; hämtningen är schemalagd och går inte att trigga utifrån. responses: '200': description: Senaste korrekt inlästa leveransen content: application/json: schema: type: object required: [data] properties: data: { $ref: '#/components/schemas/LastFileDate' } examples: normal_dygn: summary: Båda leveranserna inlästa i morse value: data: last_file_date: '2026-09-17' imported_at: '2026-09-18T05:22:13Z' total: file_date: '2026-09-17' imported_at: '2026-09-18T05:22:13Z' incremental: file_date: '2026-09-17' imported_at: '2026-09-18T05:29:48Z' import_running: false inkrementell_uteblev: summary: | Totalen kom in, den inkrementella hänger kvar på gårdagen. För de flesta medlemmar är datan ett dygn äldre än last_file_date antyder. value: data: last_file_date: '2026-09-17' imported_at: '2026-09-18T05:22:13Z' total: file_date: '2026-09-17' imported_at: '2026-09-18T05:22:13Z' incremental: file_date: '2026-09-16' imported_at: '2026-09-17T05:31:02Z' import_running: true inget_inlast: summary: Nyuppsatt miljö - ingenting hämtat än value: data: last_file_date: null imported_at: null total: null incremental: null import_running: false '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/plants: get: operationId: listPlants summary: Lista produktionsplatser (anläggningar) x-status: live security: - CustomerToken: [] responses: '200': description: Platser content: application/json: schema: type: object properties: data: type: array items: { $ref: '#/components/schemas/Plant' } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/plants/{id}: get: operationId: showPlant summary: Hämta en produktionsplats x-status: live security: - CustomerToken: [] parameters: - name: id in: path required: true schema: { type: integer } responses: '200': description: Plats content: application/json: schema: type: object properties: data: { $ref: '#/components/schemas/Plant' } '404': { $ref: '#/components/responses/NotFound' } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/plants/{ssn}/by_ssn: get: operationId: plantsBySsn summary: Produktionsplatser för en djurhållares personnummer x-status: live security: - CustomerToken: [] parameters: - name: ssn in: path required: true schema: { type: string } description: Djurhållarens personnummer (modul-10-validerat). responses: '200': description: Platser där djurhallare_ssn matchar content: application/json: schema: type: object properties: data: type: array items: { $ref: '#/components/schemas/Plant' } '422': { $ref: '#/components/responses/Unprocessable' } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/members: post: operationId: createMember summary: Skapa/uppdatera medlem (upsert på ssn) description: | Idempotent på `ssn` inom verksamheten. 201 vid nyskapande, 200 om medlemmen redan fanns och uppdaterades. Om `current_consent` utelämnas skickas en samtyckes-SMS; ogiltigt telefonnummer ger 422 men medlemmen skapas ändå. Triggar en medlems-webhook (se `webhooks`). x-status: live security: - CustomerToken: [] BusinessUuid: [] requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/MemberInput' } responses: '201': { description: Ny medlemskoppling skapad, content: { application/json: { schema: { $ref: '#/components/schemas/MemberResponse' } } } } '200': { description: Befintlig medlem uppdaterad, content: { application/json: { schema: { $ref: '#/components/schemas/MemberResponse' } } } } '422': { $ref: '#/components/responses/Unprocessable' } '401': { $ref: '#/components/responses/Unauthorized' } get: operationId: listMembers summary: Lista medlemmar för verksamheten (kompakt form) x-status: live security: - CustomerToken: [] BusinessUuid: [] responses: '200': description: Lista content: application/json: schema: type: array items: { $ref: '#/components/schemas/MemberListItem' } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/members/{id}: get: operationId: showMember summary: Hämta medlem (id = member-id upplöst via verksamheten) x-status: live security: - CustomerToken: [] BusinessUuid: [] parameters: - name: id in: path required: true schema: { type: integer } responses: '200': { description: Medlem, content: { application/json: { schema: { $ref: '#/components/schemas/MemberResponse' } } } } '400': { description: BusinessUuid-header saknas, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorMessage' } } } } '404': { $ref: '#/components/responses/NotFound' } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/members/{ssn}/find_by_ssn: get: operationId: findMemberBySsn summary: Hitta medlem via personnummer x-status: live security: - CustomerToken: [] BusinessUuid: [] parameters: - name: ssn in: path required: true schema: { type: string } responses: '200': { description: Medlem, content: { application/json: { schema: { $ref: '#/components/schemas/MemberResponse' } } } } '422': { $ref: '#/components/responses/Unprocessable' } '404': { $ref: '#/components/responses/NotFound' } '400': { description: BusinessUuid-header saknas, content: { application/json: { schema: { $ref: '#/components/schemas/ErrorMessage' } } } } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/cdb/{ssn}/ppns_for_ssn: get: operationId: ppnsForSsn summary: Produktionsplatser + djurantal för en medlems personnummer (samtyckesgatat) description: | Kräver hela kedjan: giltigt ssn → medlem finns → kund↔verksamhet-koppling → verksamhet↔medlem-koppling → **aktivt samtycke**. Saknas något → 401 (samtyckesfel) eller 404 (medlem saknas) eller 422 (ogiltigt ssn). Ett samtycke som kossorna.se samlar in själv räknas som aktivt FÖRST vid godkännande, inte när signeringen startas. Mellan utskickad SMS-länk och signatur i appen svarar dessa endpoints alltså 401. x-status: live security: - CustomerToken: [] BusinessUuid: [] parameters: - name: ssn in: path required: true schema: { type: string } responses: '200': description: Platser med djurantal content: application/json: schema: type: object properties: data: type: array items: { $ref: '#/components/schemas/PpnHolding' } '422': { $ref: '#/components/responses/Unprocessable' } '404': { $ref: '#/components/responses/NotFound' } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/cdb/{ssn}/ppns_with_animals_for_ssn: get: operationId: ppnsWithAnimalsForSsn summary: Produktionsplatser med individlista för en medlems personnummer (samtyckesgatat) description: | Som ppns_for_ssn men returnerar individuella djur per plats, i tre listor: - `current` - levande djur som står på anläggningen nu. - `dead` - djur som dött här de senaste **40 dagarna**, med `avliden_datum`. - `departed` - djur som lämnat anläggningen de senaste **40 dagarna** utan att dö här, med `avgick_datum`. Poängen med de två senare är att ett djur aldrig ska bara tyst försvinna mellan två anrop: det anslutande systemet får se *vad* som hände och hinner spegla det. Efter 40 dagar faller djuret ur svaret helt. Ett djur som lämnade anläggningen och dog någon annanstans hamnar i `departed`, inte `dead` - härifrån sett flyttade det. Klassningen görs på djurets `avliden_datum`, inte på plats-perioden. Ett djur som redan var dött när vi importerade det första gången får nämligen en öppen plats-period av importen, och räknades tidigare som levande - det var därför döda djur (några döda sedan 2005) låg kvar i `animals`. OBS inkonsekvent kuvert: detta svar är en **bar lista** (ingen `data`-wrapper), till skillnad från ppns_for_ssn. x-status: live security: - CustomerToken: [] BusinessUuid: [] parameters: - name: ssn in: path required: true schema: { type: string } responses: '200': description: Bar lista av platser med djur content: application/json: schema: type: array items: { $ref: '#/components/schemas/PpnWithAnimals' } '422': { $ref: '#/components/responses/Unprocessable' } '404': { $ref: '#/components/responses/NotFound' } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/cdb/{ssn}/animals/{id}: get: operationId: animal summary: Allt vi har om ett djur (samtyckesgatat) description: | `id` är antingen djurets **uuid** eller dess **fulla identitet** skriven som i listsvaret, `SE-900001-0222-2`. Zeropaddningen är frivillig. Uppslaget på identitet går mot djurets *alla* identiteter, inte bara den aktuella - en identitet djuret hade före en omärkning leder alltså rätt. Samma behörighetskedja som listorna, och djuret måste dessutom stå på en anläggning som samtycket täcker. Avgränsningen är densamma som `ppns_with_animals_for_ssn` listar: allt som ligger i `current`, `dead` eller `departed` går att hämta här. Ett djur som lämnade anläggningen eller dog för mer än **40 dagar** sedan ger 404. x-status: live security: - CustomerToken: [] BusinessUuid: [] parameters: - name: ssn in: path required: true schema: { type: string } - name: id in: path required: true description: Djurets uuid eller fulla identitet. schema: { type: string } responses: '200': description: Djuret content: application/json: schema: type: object properties: data: { $ref: '#/components/schemas/AnimalDetails' } '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/Unprocessable' } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/cdb/{ssn}/dead: get: operationId: deadForSsn summary: Djur som dött hos medlemmen, för registerhållning (samtyckesgatat) description: | Medlemsnivå - inte per anläggning. De två åtgärder ett journalsystem vidtar (markera som död, avföra) gäller djuret, inte platsen. Anläggningsvyn med `current`/`dead`/`departed` finns i `ppns_with_animals_for_ssn`. ## Fönstret räknas på när vi bokförde händelsen `from_days`/`to_days` räknas bakåt från idag, inklusiva i båda ändar, och mäter **när kossorna registrerade dödsfallet** - inte `avliden_datum`. Skälet: SJV kan leverera ett dödsfall långt i efterhand. En tappad inkrementell dag är ett permanent hål, och beskedet kommer då först med medlemmens nästa totalleverans - en rotation på 200 medlemmar per dag. Räknat på `avliden_datum` skulle ett djur som dog för 80 dagar sedan men levererades idag aldrig synas i något fönster: det gick rakt från `current` till borta, tyst. Priset är att `avliden_datum` kan ligga betydligt längre bak än `registrerad_datum`. Det är avsikten. ## Tänkt användning | fönster | åtgärd | |---|---| | `from_days=0&to_days=40` | markera djuret som dött, behåll det i registret | | `from_days=41&to_days=60` | avför djuret | Välj `to_days` efter er egen hämtningstakt: fönstret måste vara bredare än intervallet mellan två hämtningar, annars kan ett djur hinna passera förbi mellan två anrop. 41-60 förutsätter alltså att ni hämtar minst var tjugonde dag. Bokföringsögonblicket är ett eget fält (`end_recorded_at` på plats-perioden) som sätts en gång när perioden stängs och aldrig skrivs om. Perioder som saknar det kommer inte med: allt som stängdes innan fältet fanns, och djur vars period aldrig stängdes alls. Historiska rader lämnas med flit utan värde - vi vet inte när vi fick veta, och en gissning skulle svämma över fönstret. x-status: live security: - CustomerToken: [] BusinessUuid: [] parameters: - name: ssn in: path required: true schema: { type: string } - name: from_days in: query required: false description: Undre gräns i dagar bakåt, inklusive. Standard 0. schema: { type: integer, minimum: 0, default: 0 } - name: to_days in: query required: false description: | Övre gräns i dagar bakåt, inklusive. Standard 40, tak 365 - taket finns för att SJV aldrig rensar döda djur ur totalfilen (äldsta i leveransen dog 2005), så ett otaket anrop skulle dra hela beståndets samtliga dödsfall. schema: { type: integer, minimum: 0, maximum: 365, default: 40 } responses: '200': description: Djur bokförda som döda inom fönstret content: application/json: schema: type: object properties: data: type: array items: { $ref: '#/components/schemas/DeadRegisterEntry' } '422': { $ref: '#/components/responses/Unprocessable' } '404': { $ref: '#/components/responses/NotFound' } '401': { $ref: '#/components/responses/Unauthorized' } /api/v1/cdb/{ssn}/departed: get: operationId: departedForSsn summary: Djur som lämnat medlemmen, för registerhållning (samtyckesgatat) description: | Som `/cdb/{ssn}/dead`, men för djur som lämnat medlemmen. Samma fönsterregel: `from_days`/`to_days` mäter när **vi** bokförde avgången, inte `avgick_datum`. Ett djur som flyttat mellan medlemmens **egna** anläggningar räknas inte som avgånget - det har fortfarande en öppen period i uppsättningen, och ni behöver bara byta anläggning på djuret. Gränsen går vid samtyckets anläggningar, alltså inklusive `ssn1`-`ssn4` (medägare och familj). Dog djuret efter att det lämnat medlemmen hamnar det här, inte i `dead`: härifrån sett är händelsen att det försvann. | fönster | åtgärd | |---|---| | `from_days=0&to_days=40` | markera djuret som avgånget | | `from_days=41&to_days=60` | avaktivera djuret | x-status: live security: - CustomerToken: [] BusinessUuid: [] parameters: - name: ssn in: path required: true schema: { type: string } - name: from_days in: query required: false schema: { type: integer, minimum: 0, default: 0 } - name: to_days in: query required: false schema: { type: integer, minimum: 0, maximum: 365, default: 40 } responses: '200': description: Djur bokförda som avgångna inom fönstret content: application/json: schema: type: object properties: data: type: array items: { $ref: '#/components/schemas/DepartedRegisterEntry' } '422': { $ref: '#/components/responses/Unprocessable' } '404': { $ref: '#/components/responses/NotFound' } '401': { $ref: '#/components/responses/Unauthorized' } /healthz: get: operationId: healthz summary: Hälsokontroll x-status: live security: [] responses: '200': { description: 'ok', content: { text/plain: { schema: { type: string } } } } # ============================================================================= # Webhooks (det enda kossorna initierar) # ============================================================================= webhooks: memberUpdate: get: operationId: webhookMemberUpdate summary: Medlem uppdaterad (enda live-webhooken) description: | Kossorna skickar en **GET utan body** till kundens konfigurerade `webhook_url`. URL-form: `{webhook_url}/{BUSINESS_SSN}/business/{MEMBER_SSN}/member` (i icke-produktion läggs `?dev=true` till). Trigg-events internt (mottagaren ser INTE vilket — inget event-namn skickas): medlem skapad/uppdaterad, samtycke godkänt, samt SMS-statusuppdatering från BestSMS. Mottagaren svarar 2xx för att bekräfta. Icke-2xx/timeout (10s) → durabel retry enligt nedan. **Mottagar-URL:** lagras per KUND (`customers.webhook_url`), sätts internt. Ingen self-service-registrering finns idag. **Retry:** initialt synkront anrop + 3 durabla retries (10 min, 2 h, 24 h). Kön ligger i DB (`webhook_deliveries`) och sveps av `WebhookRetryManager`, så leveranser **överlever omstart**. Leveransgaranti: **durable at-least-once — men först efter att kö-raden skrivits**. Själva förstaförsökets överlämning till kön är best-effort (om noden dör mellan medlems-commit och kö-insert kan en notis tappas). Samma ping kan också levereras mer än en gång vid krasch-/race-fönster, så behandla den som idempotent (den bär ingen data, bara "hämta om medlemmen"). Ingen leverans-/idempotensnyckel skickas ännu (se `x-status: proposed`). **Verifiering:** headers `X-Customer-Id` och `X-Member-SSN` (plus `User-Agent: Kossorna.se/1.0`). Dessa är oautentiserade identifierare — det finns INGEN signatur/HMAC idag (se `x-status: proposed` nedan). x-status: live parameters: - name: X-Customer-Id in: header schema: { type: string } - name: X-Member-SSN in: header schema: { type: string } responses: '200': { description: Mottagen och bekräftad } consentRevoked: post: operationId: webhookConsentRevoked summary: Samtycke återkallat (PROPOSED — finns ej) description: | Efterfrågat av Vetmanager men INTE implementerat. Ingen webhook triggas vid återkallat samtycke idag. Skiss nedan för framtida bygge. x-status: proposed requestBody: content: application/json: schema: type: object properties: event: { type: string, const: consent_revoked } member_ssn: { type: string } business_ssn: { type: string } revoked_at: { type: string, format: date-time } delivery_id: { type: string, description: Idempotensnyckel (proposed) } responses: '200': { description: Mottagen } cdbDataAvailable: post: operationId: webhookCdbDataAvailable summary: Ny CDB-data tillgänglig (PROPOSED — finns ej) description: | Efterfrågat: notifiera när dagens SJV-import gett ny djurdata för en medlem/plats. INTE implementerat — SJV-kedjan triggar inga webhooks idag. x-status: proposed requestBody: content: application/json: schema: type: object properties: event: { type: string, const: cdb_data_available } member_ssn: { type: string } delivery_id: { type: string } responses: '200': { description: Mottagen } plantChanged: post: operationId: webhookPlantChanged summary: Produktionsplats ändrad (PROPOSED — finns ej) x-status: proposed requestBody: content: application/json: schema: type: object properties: event: { type: string, const: plant_changed } ppn: { type: string } member_ssn: { type: string } delivery_id: { type: string } responses: '200': { description: Mottagen }