Gridex Developers

Website API, Mina sidor-koppling och webhooks

Den här guiden är den publika online-dokumentationen för tenants och webbteam. Den visar hur en hemsida hämtar publicerade avtal, skickar kundansökningar, kopplar Mina sidor mot webbens Supabase-inloggning och tar emot händelser via webhook.

Dokumentationsversion: 2026-07-13.2

Base URL
https://app.gridex.se
Auth
Bearer API key
Support
Supportärenden ligger utanför API:t.

1. Autentisering och säkerhet

API-nyckeln identifierar vilket bolag integrationen tillhör. Lägg nyckeln i servermiljö, aldrig i publik frontend.

Authorization: Bearer YOUR_GRIDEX_API_TOKEN
Origin: https://www.exempel.se
Content-Type: application/json

Allowed origins skyddar webbläsaranrop. Server-till-server-anrop kan sakna Origin-header och ska därför alltid hålla API-nyckeln hemlig. API:t filtrerar data per bolag från nyckeln; klienten ska inte skicka egen bolagsidentifierare.

2. Behörigheter

I vanliga ordTeknisk behörighetBetydelse
Läsa avtal på hemsidanwebsite_contracts.readHämta publicerade elavtal för rätt bolag.
Skicka kundansökningarwebsite_applications.writeSkicka in kund, anläggning, valt avtal och juridiska godkännanden.
Mina sidor – läsa kunddatacustomer_portal.readLäsa kundprofil, avtal, anläggningar, fakturor, dokument och händelser.
Mina sidor – uppdatera kunddatacustomer_portal.writeSkicka kompletteringar, flyttanmälan och profiländringar.
Läsa händelserevents.readLäsa händelser som skapats för bolaget.
Skicka händelser från hemsidanwebsite_events.writeSkicka kundhändelser från hemsida eller kundportal.
Läsa kunddokumentcustomer_documents.readAktiv granulär behörighet. Legacy-scope customer_portal.read accepteras tillfälligt.
Synka kunddokumentcustomer_documents.writeAktiv granulär behörighet för dokumentoperationer. /customer/sync kräver customer_sync.write.
Läsa kundnotisercustomer_notifications.readAktiv granulär behörighet. Legacy-scope customer_portal.read accepteras tillfälligt.
Uppdatera kundnotisercustomer_notifications.writeAktiv granulär behörighet för /notifications/read. Legacy-scope customer_portal.write accepteras tillfälligt.

Standardpaketet för hemsida/Mina sidor bör innehålla alla rekommenderade behörigheter. Kundroutes filtrerar alltid per bolag från API-nyckeln.

  • Kontaktuppgifter: customer_contact.write
  • Anläggningsuppgifter: customer_facility_data.write
  • Fullmakt: customer_power_of_attorney.write

3. Endpoints

MetodPathBehörighetBeskrivning
GET/api/v1/website/public-contractswebsite_contracts.readHämta publicerade avtal som hemsidan får visa.
POST/api/v1/website/quotewebsite_contracts.readBeräkna pris för ett publicerat erbjudande.
GET/api/v1/website/legal-bundlewebsite_legal.read eller website_contracts.readHämta publicerade juridikversioner och länkar. Ett av angivna scopes räcker.
POST/api/v1/website/customer-applicationswebsite_applications.writeSkapa kundansökan och juridiska godkännanden. Idempotency-Key krävs.
POST/api/v1/website/customer-eventswebsite_events.writeSkicka kundhändelse från hemsida eller kundportal. Idempotency-Key krävs.
POST/api/v1/eventswebsite_events.writeSkicka kundhändelse. Idempotency-Key krävs.
GET/api/v1/eventsevents.readLäs bolagets domänhändelser.
GET/api/v1/customer/portal-bundlecustomer_portal.readHämta kundportalens samlade läsmodell med verifierad portalidentitet.
POST/api/v1/customer/portal-bundlecustomer_portal.readHämta kundportalens samlade läsmodell med verifierad portalidentitet.
POST/api/v1/customer-portal/synccustomer_sync.writeLänka eller granska extern portalidentitet. Idempotency-Key krävs.
POST/api/v1/customer/synccustomer_sync.writeSynka kundkompletteringar till OPS. Idempotency-Key krävs.
GET/api/v1/customer/mecustomer_profile.readHämta länkad kundprofil.
GET/api/v1/customer/contractscustomer_contracts.readHämta kundens avtal.
GET/api/v1/customer/sitescustomer_sites.readHämta kundens anläggningar.
GET/api/v1/customer/invoicescustomer_invoices.readHämta kundens fakturor.
GET/api/v1/customer/invoices/[id]customer_invoices.readHämta en faktura.
GET/api/v1/customer/metering-valuescustomer_metering.readHämta kundens mätvärden.
GET/api/v1/customer/eventscustomer_events.readHämta kundens händelser.
GET/api/v1/customer/documentscustomer_documents.readHämta kundens dokument utan interna lagringsvägar.
GET/api/v1/customer/legal-acceptancescustomer_legal.readHämta kundens juridiska godkännanden.
GET/api/v1/customer/powers-of-attorneycustomer_power_of_attorney.readHämta kundens fullmakter.
GET/api/v1/customer/notificationscustomer_notifications.readHämta kundens notiser.
POST/api/v1/customer/notifications/readcustomer_notifications.writeMarkera kundnotiser som lästa. Idempotency-Key krävs.
POST/api/v1/customer/profile-updatecustomer_contact.write eller customer_facility_data.writeSkicka profil- eller anläggningsadressändring. Idempotency-Key krävs.
POST/api/v1/customer/move-outcustomer_facility_data.writeSkicka flyttanmälan. Idempotency-Key krävs.

4. Hämta publicerade avtal

Svaret innehåller bara avtal som är publicerade, aktiva för hemsida/API, datumgiltiga, kopplade till aktiv prisversion/prislista och har publicerad juridik.

curl -X GET "https://app.gridex.se/api/v1/website/public-contracts?customer_type=private" \
  -H "Authorization: Bearer YOUR_GRIDEX_API_TOKEN" \
  -H "Accept: application/json"
{
  "data": [
    {
      "id": "offer_...",
      "offer_reference": "offer_...",
      "code": "RORLIGT-ELPRIS",
      "name": "Rörligt elpris",
      "type": "variable_spot",
      "customer_type": "both",
      "pricing": {
        "monthly_fee": { "amount": 68, "currency": "SEK", "unit": "month" },
        "markup": { "amount": 4, "unit": "ore_per_kwh" },
        "invoice_fee": { "amount": 0, "currency": "SEK", "unit": "invoice" },
        "spot_share": null,
        "portfolio_share": null
      },
      "legal": {
        "terms_version": "2026-06",
        "terms_version_id": "legal_terms_uuid",
        "terms_url": "https://app.gridex.se/legal/.../terms/legal_terms_uuid",
        "privacy_policy_version": "2026-06",
        "privacy_policy_version_id": "legal_privacy_uuid",
        "privacy_policy_url": "https://app.gridex.se/legal/.../privacy/legal_privacy_uuid",
        "withdrawal_version": "2026-06",
        "withdrawal_version_id": "legal_withdrawal_uuid",
        "withdrawal_url": "https://app.gridex.se/legal/.../withdrawal/legal_withdrawal_uuid",
        "power_of_attorney_required": true,
        "power_of_attorney_version": "2026-06",
        "power_of_attorney_version_id": "legal_poa_uuid",
        "power_of_attorney_url": "https://app.gridex.se/legal/.../power-of-attorney/legal_poa_uuid",
        "price_terms_version": "2026-06",
        "price_terms_version_id": "legal_price_terms_uuid",
        "price_terms_url": "https://app.gridex.se/legal/.../price-terms/legal_price_terms_uuid"
      },
      "valid_from": "2026-06-01",
      "valid_to": null
    }
  ]
}

Hemsidan ska använda exakt offer_reference från svaret när kunden tecknar avtal. Det är den enda avtalsväljaren. product_code, price_plan_id, price_plan_version_id och contract_offer_id får inte användas för att välja avtal; motstridiga legacyfält ger 422 offer_selector_mismatch.

Juridiken i legal är OPS source of truth. Visa dokumentlänkarna från OPS och skicka separata consent-flaggor. OPS binder accepten server-side till exakt de fem versions-ID:n som ligger i det valda erbjudandet. När fullmakt krävs ska powerOfAttorney.textVersionId vara legal.power_of_attorney_version_id.

Vid publiceringsfelsökning kan tenantens backend använda diagnostics=1. Svaret förklarar per erbjudande varför det är synligt eller blockerat, exempelvis status, datum, kundtyp, prisbok, prisplansversion eller juridikpaket.

curl -X GET "https://app.gridex.se/api/v1/website/public-contracts?customer_type=private&diagnostics=1" \
  -H "Authorization: Bearer YOUR_GRIDEX_API_TOKEN" \
  -H "Accept: application/json"

# Svaret innehåller visible/hidden och blockers per public_contract_offer.
# Använd detta server-side vid publiceringsfelsökning; visa inte intern diagnostik i kundens UI.

5. Skicka kundansökan

Kundansökan ska innehålla valt offer_reference, fem separata juridiska godkännanden och, när kunden redan är inloggad på hemsidan, webbens Supabase session.user.id som både customer_portal_user_id och auth_user_id. OPS skapar kund, kundnummer, portal identity, prissnapshot och ett först väntande avtal. Därefter verifierar en atomisk serverfunktion de exakta juridikversionerna och sätter status=signed, signed_at, ångerfrist och signature_snapshot_sha256. Klientens egna signed_at/acceptedAt används inte som avtalets juridiska signeringstid.

Direkt efter lyckad signering köas mottagningsmail, avtalsbekräftelse med fryst PDF och ångerrättsmail enligt tenantens regler. Detta är frikopplat från anläggningsuppslagning och leverantörsbyte. can_send_agreement_confirmation beskriver juridisk behörighet att skicka bekräftelsen och är därför oberoende av can_start_switch. Läs communication.queued/sent/failed för faktisk status.

curl -X POST "https://app.gridex.se/api/v1/website/customer-applications" \
  -H "Authorization: Bearer YOUR_GRIDEX_API_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: website-order-12345" \
  -d '{
    "external_customer_id": "CUSTOMER-12345",
    "source": "exempel.se",
    "customer": {
      "customer_type": "private",
      "first_name": "Anna",
      "last_name": "Andersson",
      "email": "anna@example.se",
      "phone": "+46701234567",
      "personal_number": "YYYYMMDDXXXX"
    },
    "site": {
      "facility_id": null,
      "street": "Storgatan 1",
      "postal_code": "21122",
      "city": "Malmö",
      "price_area_code": "SE4",
      "move_in_date": "2026-07-01",
      "current_supplier_name": "Nuvarande Energi AB",
      "current_supplier_org_number": "5560000000",
      "current_supplier_ediel_id": "12345"
    },
    "contract": {
      "offer_reference": "offer_...",
      "requested_start_mode": "specific_date",
      "requested_start_date": "2026-07-01"
    },
    "customer_portal_user_id": "<gridex-web-supabase-session-user-id>",
    "auth_user_id": "<gridex-web-supabase-session-user-id>",
    "consents": {
      "terms": true,
      "privacy_policy": true,
      "withdrawal": true,
      "power_of_attorney": true,
      "price_terms": true
    },
    "powerOfAttorney": {
      "accepted": true,
      "scope": ["supplier_switch", "facility_information_lookup"],
      "signerName": "Anna Andersson",
      "signerIdentityNumber": "YYYYMMDDXXXX",
      "method": "website_acceptance",
      "acceptedAt": "2026-06-26T09:00:00Z",
      "textVersionId": "legal_poa_uuid",
      "ipAddress": "203.0.113.10",
      "userAgent": "Mozilla/5.0 ..."
    }
  }'
{
  "data": {
    "customer_id": "uuid",
    "customer_number": "DX-100025",
    "application_id": "uuid",
    "application_number": "APP-20260616-0001",
    "external_customer_id": "CUSTOMER-12345",
    "portal_identity_id": "uuid",
    "customer_site_id": "uuid",
    "metering_point_id": "uuid",
    "contract_id": "uuid",
    "contract_number": "AVT-DX-100025-001",
    "contract_price_snapshot_id": "uuid",
    "offer_reference": "offer_...",
    "contract_status": "signed",
    "signed_at": "2026-06-26T09:00:01.123Z",
    "withdrawal_deadline_at": "2026-07-10T09:00:01.123Z",
    "signature_snapshot_sha256": "4d7f...64_hex_characters...9a2c",
    "can_send_agreement_confirmation": true,
    "can_start_switch": false,
    "status": "application_received",
    "missing_fields": [],
    "blocking_reasons": [],
    "power_of_attorney_id": "uuid",
    "power_of_attorney": { "status": "signed", "scope": ["supplier_switch", "facility_information_lookup"], "method": "website_acceptance", "externally_sendable": true, "requires_completion": false },
    "nextAction": { "code": "facility_identifier_requested", "message": "Anläggnings-ID saknas. Uppgifter har begärts från nätägaren via e-post." },
    "manualInformationRequest": { "status": "manual_email_queued", "case_reference": "GX-FIR-AB12CD34", "channel": "manual_email", "request_id": "uuid" },
    "next_step": "Granska ansökan och fortsätt enligt bolagets process.",
    "communication": {
      "triggered": ["contract.application_received", "contract.confirmation_sent", "contract.cooling_off_sent"],
      "queued": ["contract.application_received", "contract.confirmation_sent", "contract.cooling_off_sent"],
      "sent": [],
      "failed": [],
      "source_of_truth": "communication_logs"
    },
    "warnings": []
  }
}

422-validering och juridiska retries

Om avtal, juridiska versioner, acceptanser, datum eller fullmakt saknas/är ogiltiga returneras 422 med stabil error.code, stage och field. requested_start_mode accepterar endast earliest_possible eller specific_date; datum ska vara YYYY-MM-DD och powerOfAttorney.acceptedAt ska vara ISO 8601. Okända top-level- och nested-fält returnerar unknown_field i stället för att ignoreras.

Idempotency-Key är obligatorisk för kundansökan. Återanvänd nyckeln endast med exakt samma normaliserade payload. En ändrad payload ger 409 idempotency_key_payload_mismatch, en pågående request ger 409 idempotency_in_progress, en identisk committed ansökan under ny nyckel ger 409 duplicate_application, en parallell pågående affärshändelse ger 409 application_business_in_progress och en redan aktiv/committed affärshändelse ger 409 application_business_conflict.

Nuvarande leverantör kan skickas under site.current_supplier_name, current_supplier_id, current_supplier_org_number, current_supplier_ediel_id och kompletterande avtalsfält. Leverantörs-ID:t snapshotas på både site och switch request. Svaret skiljer på can_create_supplier_switch_request och can_dispatch_supplier_switch. En skapad men blockerad switch returnerar supplier_switch_status=pending_review och en konkret nextAction. När site, mätpunkt, nuvarande leverantör eller signerad fullmakt kompletteras körs reconcile och en saknad/öppen switch kan skapas eller återupptas.

HTTP/1.1 422 Unprocessable Entity
{
  "error": {
    "code": "legal_acceptance_missing",
    "message": "Kunden måste godkänna allmänna villkor, integritetspolicy, ångerrätt, fullmakt och prisvillkor innan ansökan kan skickas.",
    "stage": "legal_acceptance",
    "field": "consents.terms",
    "hint": "Visa alla juridiska checkboxar från OPS publicerade juridikpaket och skicka true för varje required consent."
  }
}

Vanliga 422-koder:
- public_contract_required
- offer_reference_required
- offer_reference_mismatch
- offer_selector_mismatch
- public_contract_not_available
- offer_legal_versions_missing
- offer_legal_versions_invalid
- offer_legal_version_mismatch
- legal_acceptance_missing
- power_of_attorney_missing
- power_of_attorney_not_accepted
- power_of_attorney_version_missing
- requested_start_mode_invalid
- date_invalid
- timestamp_invalid
- unknown_field
- validation_error

Idempotency:
- Idempotency-Key är obligatorisk för POST /api/v1/website/customer-applications och ska vara 8–200 tillåtna tecken.
- Återanvänd samma nyckel endast med exakt samma normaliserade payload.
- Samma nyckel + annan payload ger 409 idempotency_key_payload_mismatch.
- Samma nyckel medan första requesten pågår ger 409 idempotency_in_progress.
- Identisk committed ansökan med en ny nyckel ger 409 duplicate_application.
- Samma kund/anläggning/erbjudande/startdatum som redan behandlas under annan nyckel ger 409 application_business_in_progress.
- Samma affärshändelse som redan är aktiv/committed ger 409 application_business_conflict.
- Replay av en committed status returnerar samma warnings och communication-snapshot som originalsvaret.
- Rätta en ogiltig payload innan första godkända requesten, eller använd en ny nyckel efter ett avslutat fel enligt den returnerade action/hint.

6. Obligatoriskt: Mina sidor-koppling

Det här flödet heter Customer Portal External Auth Linking. På svenska kallar vi det Mina sidor-koppling. Det ska användas när tenantens hemsida har egen Supabase Auth och OPS inte har kunden i sin egen auth.users.

Tenantens backend måste skicka webbens Supabase session.user.id till OPS tillsammans med en stabil kundnyckel. API-nyckeln avgör alltid bolag/tenant; hemsidan ska aldrig skicka company_id eller ett fritt customer_id.

Authorization: Bearer YOUR_GRIDEX_API_TOKEN
x-gridex-customer-portal-user-id: <gridex-web-supabase-session-user-id>
x-gridex-auth-user-id: <gridex-web-supabase-session-user-id>
x-gridex-external-customer-id: <external_customer_id>
# eller, om external_customer_id saknas:
x-gridex-customer-number: DX-100025
# optional fallback:
x-gridex-customer-email: kund@example.se
Gridex-webb Supabase session.user.id
→ x-gridex-customer-portal-user-id till OPS
→ OPS matchar tenant via API-nyckeln
→ OPS matchar kund via redan länkad auth-user, riktigt external_customer_id eller kundnummer + e-post
→ första auto-länkning kräver redan länkad användare eller minst två matchande kunduppgifter
→ OPS skapar/uppdaterar customer_portal_accounts.role = owner
→ OPS fyller customer_portal_identities.auth_user_id och customer_portal_user_id
→ GET /api/v1/customer/portal-bundle returnerar kundens data
Tenantens backend ska:
1. läsa Supabase session.user.id server-side
2. skicka user.id i x-gridex-customer-portal-user-id
3. skicka samma user.id i x-gridex-auth-user-id
4. skicka external_customer_id från ansökan eller customer_number från OPS
5. aldrig skicka company_id eller customer_id från frontend
6. använda POST /api/v1/customer/portal-bundle som huvudendpoint för Mina sidor

OPS skapar eller uppdaterar då customer_portal_accounts med rollen owner och fyller customer_portal_identities.auth_user_id, customer_portal_identities.customer_portal_user_id och external_account_id. Värdet customer är inte en giltig portalroll. Skicka inte OPS-kundnummer som external_customer_id; använd customer_number när det är kundnumret.

OPS-kundnummer är tenantens kundnummer. Fakturapartners som Capway ska senare kopplas via separata fält som billing_customer_ref och provider-metadata, inte genom att skriva över customer_number eller blanda ihop det med external_customer_id.

7. Hämta Mina sidor-data

Tenantens Mina sidor ska anropa OPS server-side med exakt kundidentifiering från den inloggade kunden. Rekommenderad JSON-payload är email, customer_number och external_customer_id.

{
  "email": "heke99@live.se",
  "customer_number": "DX-100023",
  "external_customer_id": "GRIDEX-WEB-20260616-8191257d-88d3-4929-ab02-1d3ca5ed986f"
}
fetch("https://app.gridex.se/api/v1/customer/portal-bundle", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer YOUR_GRIDEX_API_TOKEN"
  },
  body: JSON.stringify({
    email: session.user.email,
    customer_number: localCustomer.customerNumber,
    external_customer_id: localCustomer.externalCustomerId
  }),
  cache: "no-store"
})

Headers/query stöds fortsatt för äldre implementationer:

fetch("https://app.gridex.se/api/v1/customer/portal-bundle", {
  headers: {
    Authorization: "Bearer YOUR_GRIDEX_API_TOKEN",
    "x-gridex-customer-portal-user-id": "<gridex-web-supabase-session-user-id>",
    "x-gridex-auth-user-id": "<gridex-web-supabase-session-user-id>",
    "x-gridex-external-customer-id": "CUSTOMER-12345",
    "x-gridex-customer-number": "DX-100025"
  },
  cache: "no-store"
})
{
  "data": {
    "profile": {
      "customer_number": "DX-100023",
      "display_name": "Hekmat Hourani",
      "email": "heke99@live.se"
    },
    "customer_status": {
      "code": "needs_facility_data",
      "label": "Ansökan behandlas",
      "message": "Vi behöver komplettera anläggningsuppgifter innan leverantörsbytet kan starta.",
      "can_start_switch": false
    },
    "data_quality": {
      "status": "needs_action",
      "issues": ["missing_metering_point", "missing_grid_owner", "facility_not_verified"]
    }
  }
}

Alla kundroutes filtrerar på bolag från API-nyckeln och löser kunden via riktigt external_customer_id, kundnummer eller unik e-post. Om flera kunder matchar samma e-post returneras 409 ambiguous_customer_match. Saknade listor returneras som tomma arrayer, inte 500.

8. Synka dokument, fullmakt och juridiska godkännanden till OPS

Godkända fullmakter, juridiska godkännanden och dokument ska skickas till OPS så att OPS kan starta rätt automatiska processer. Använd POST /api/v1/customer/sync. Anropet är tenant-säkert: API-nyckeln avgör bolag och payloaden får inte innehålla fritt company_id.

curl -X POST "https://app.gridex.se/api/v1/customer/sync"   -H "Authorization: Bearer YOUR_GRIDEX_API_TOKEN"   -H "Content-Type: application/json"   -H "Idempotency-Key: tenant-sync-12345"   -d '{
    "email": "heke99@live.se",
    "customer_number": "DX-100023",
    "external_customer_id": "GRIDEX-WEB-20260616-8191257d-88d3-4929-ab02-1d3ca5ed986f",
    "power_of_attorney": {
      "scope": "supplier_switch",
      "status": "signed",
      "signed_at": "2026-06-16T15:10:12.647Z",
      "legal_text_version": "2026-06-12-v1",
      "reference": "POA-39e9fbc4-2c94-46fb-a1ee-49d18cb0932a",
      "document": {
        "external_document_id": "tenant-doc-123",
        "document_type": "power_of_attorney",
        "title": "Signerad fullmakt",
        "file_url": "https://tenant.se/documents/tenant-doc-123.pdf"
      }
    },
    "legal_acceptances": [
      { "acceptance_type": "terms", "legal_text_version": "2026-06-12-v1", "accepted_at": "2026-06-16T15:10:12.647Z" },
      { "acceptance_type": "privacy_policy", "legal_text_version": "2026-06-12-v1", "accepted_at": "2026-06-16T15:10:12.647Z" },
      { "acceptance_type": "price_snapshot", "legal_text_version": "2026-06-12-v1", "accepted_at": "2026-06-16T15:10:12.647Z" }
    ],
    "documents": [
      {
        "external_document_id": "tenant-contract-123",
        "document_type": "contract_confirmation",
        "title": "Avtalsbekräftelse",
        "file_url": "https://tenant.se/documents/tenant-contract-123.pdf"
      }
    ]
  }'

OPS sparar fullmakt i powers_of_attorney, juridiska godkännanden i customer_legal_acceptances och dokument i customer_documents. Om anläggningsdata saknas skapas statusen needs_facility_data och switch blockeras tills mätpunkt/nätägare är verifierade.

9. Webhooks

Webhookar skickas som POST till konfigurerad HTTPS-URL. Leveransen signeras med HMAC SHA-256 över timestamp.rawBody. Mottagaren ska svara 2xx när eventet är mottaget.

X-Gridex-Event-Id: event_123
X-Gridex-Event-Type: contract.application_received
X-Gridex-Timestamp: 1718532000
X-Gridex-Signature: sha256=<signature>
X-Gridex-Delivery-Id: delivery_123
{
  "id": "event_123",
  "type": "contract.application_received",
  "event_id": "event_123",
  "event_type": "contract.application_received",
  "created_at": "2026-06-16T10:30:00Z",
  "company_id": "uuid",
  "customer_id": "uuid",
  "customer_number": "DX-100025",
  "external_customer_id": "CUSTOMER-12345",
  "aggregate": { "type": "customer_contract", "id": "uuid" },
  "data": {
    "application_id": "uuid",
    "contract_id": "uuid",
    "status": "application_received"
  }
}

Aktiva/byggda events:

  • customer.created
  • customer.updated
  • customer_number.assigned
  • contract.application_received
  • contract.confirmation_sent
  • contract.cooling_off_sent
  • contract.needs_facility_data
  • power_of_attorney.signed
  • document.created
  • facility_data.received
  • facility_data.verified
  • invoice.created
  • invoice.sent
  • invoice.disputed
  • metering_values.updated

Mail- och webhook-semantik

Juridiska webhookar speglar faktisk kommunikation: contract.confirmation_sent och contract.cooling_off_sent publiceras först när respektive mail-logg är sent/delivered. I kundansökans direkta svar är samma namn däremot mall-/affärshändelser och måste alltid läsas tillsammans med dispatch_status.

I kundansökans communication-svar är eventKey mall-/affärshändelsen. Läs alltid dispatch_status/queued/sent/failed för faktisk utskicksstatus.

contract.application_received = mottagningsmail har skapats enligt tenantens regel
contract.confirmation_sent = avtalsbekräftelse med fryst avtals-PDF har skapats enligt tenantens regel
contract.cooling_off_sent = ångerrättsmail har skapats enligt tenantens regel

Webhook/domain event med samma *_sent-namn publiceras däremot först när communication_logs har markerats sent/delivered av leverantören. Ett köat mail är aldrig samma sak som levererat.

Planerade events som kan tillkomma senare:

  • customer.opened_document
  • customer.downloaded_document
  • contract.activated
  • supplier_switch.started
  • supplier_switch.completed
  • invoice.paid
import { createHmac, timingSafeEqual } from "node:crypto"
import { NextRequest, NextResponse } from "next/server"

function verify(rawBody: string, timestamp: string | null, signature: string | null) {
  if (!timestamp || !signature) return false
  const secret = process.env.GRIDEX_WEBHOOK_SIGNING_SECRET
  if (!secret) return false

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false

  const expected = "sha256=" + createHmac("sha256", secret)
    .update(timestamp + "." + rawBody)
    .digest("hex")

  const received = Buffer.from(signature)
  const wanted = Buffer.from(expected)
  return received.length === wanted.length && timingSafeEqual(received, wanted)
}

export async function POST(request: NextRequest) {
  const rawBody = await request.text()
  const ok = verify(
    rawBody,
    request.headers.get("x-gridex-timestamp"),
    request.headers.get("x-gridex-signature")
  )
  if (!ok) return NextResponse.json({ error: "invalid_signature" }, { status: 401 })

  const event = JSON.parse(rawBody)
  // Spara event.id idempotent innan ni kör affärslogik.
  return NextResponse.json({ received: true })
}

Resend-leveranswebhook och interna cron-jobb

Manuell nätägar-e-post levereransspåras via Resend-webhooken POST /api/webhooks/resend. Den verifieras mot request-body med Svix-huvuden och RESEND_WEBHOOK_SECRET. Felklasser: missing_headers (400), missing_secret (500), resend_webhook_invalid_signature (401) och event_processing_failed (500). En manuell curl utan giltiga Svix-huvuden misslyckas avsiktligt – använd Resend-dashboardens testevent och deploya om Vercel efter att miljövariabeln ändrats. RESEND_WEBHOOK_SECRET måste vara den exakta signeringshemligheten för exakt den endpoint som används.

Webhooken uppdaterar manual_email_outbox.delivery_status (sent/delivered/delivery_delayed/bounced/complained/failed/suppressed) och sätter den länkade begäran till needs_review vid negativ leverans. Interna cron-jobb skyddas med Authorization: Bearer <secret> eller x-cron-secret:

POST /api/internal/customer-operations/cron     Authorization: Bearer <CUSTOMER_OPERATION_CRON_SECRET | CRON_SECRET>
POST /api/internal/manual-email/outbox/process   Authorization: Bearer <MANUAL_EMAIL_OUTBOX_CRON_SECRET | EMAIL_OUTBOX_CRON_SECRET | CRON_SECRET>
POST /api/internal/manual-inbound/cron           Authorization: Bearer <MANUAL_INBOUND_CRON_SECRET | CRON_SECRET>   (även x-manual-inbound-secret)

10. Fel och idempotency

Alla write-anrop bör skicka Idempotency-Key; för POST /api/v1/website/customer-applications är den obligatorisk och valideras till 8–200 tecken. Samma nyckel är låst till samma normaliserade payload via SHA-256. Stabil idempotency-respons: idempotency_key_required (400), idempotency_key_invalid (400), idempotency_key_payload_mismatch (409), idempotency_in_progress (409), duplicate_application (409), application_business_in_progress (409), application_business_conflict (409) och idempotent_failed (409). En committed replay returnerar den sparade responsen inklusive warnings och communication. Ett avslutat misslyckat försök får endast retryas enligt returnerad hint; komplettering ska göras på befintlig ansökan och en verkligt ny affärshändelse ska använda ny nyckel samt annan site/offer/start-identitet.

Batch 8.1 live-schema alignment: inkommande mätpunkter provisioneras mot public.metering_points; external_customer_id krävs för stabil kundlänkning; mailinställningar stödjer sender_email och reply_to_email.