{"openapi":"3.1.0","info":{"title":"5nPay HTTP API","version":"1.0.0","description":"HTTP API surface for the 5nPay platform — UK SME fleet compliance and PCN management. Public routes (health, status, webhooks) are unauthenticated. Product routes require a Clerk session. A partner API-key scheme for programmatic fleet integrations is on the enterprise roadmap.","contact":{"name":"5nPay Engineering","email":"engineering@5npay.co.uk","url":"https://www.5npay.co.uk"},"license":{"name":"Proprietary — Dymora Ltd"}},"servers":[{"url":"https://www.5npay.co.uk","description":"Production"}],"security":[{"ClerkSession":[]}],"components":{"securitySchemes":{"ClerkSession":{"type":"apiKey","in":"cookie","name":"__session","description":"Clerk-issued session cookie. Established via the /sign-in flow."},"CronSecret":{"type":"apiKey","in":"header","name":"Authorization","description":"Bearer <CRON_SECRET> for cron-triggered internal jobs."},"PartnerApiKey":{"type":"http","scheme":"bearer","description":"Council Connector partner key: 5np_{env}_{16-hex id}_{64-hex secret}. Send as Authorization: Bearer <key> (x-api-key also accepted). Keys are environment-scoped (test|live) and authorised per council via explicit assignment — no wildcard access."},"WebhookSignature":{"type":"apiKey","in":"header","name":"stripe-signature","description":"HMAC-SHA256 signature. Stripe uses stripe-signature; GoCardless uses webhook-signature. Both are verified server-side; unsigned requests → 498."}},"responses":{"RateLimited":{"description":"Rate limit exceeded. Retry after the value in Retry-After.","headers":{"Retry-After":{"schema":{"type":"integer"}},"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"}}}},"Unauthorized":{"description":"Missing or invalid Clerk session."},"Forbidden":{"description":"Caller lacks the required capability for this action."},"NotFound":{"description":"Resource not found or not owned by caller."},"InvalidSignature":{"description":"Webhook signature verification failed."}},"schemas":{"ApiError":{"type":"object","required":["error"],"properties":{"error":{"type":"string"},"code":{"type":"string"},"details":{}}}}},"tags":[{"name":"health","description":"Liveness / readiness probes"},{"name":"consent","description":"Cookie consent (public)"},{"name":"user","description":"Signed-in user profile + onboarding"},{"name":"gdpr","description":"GDPR export + deletion"},{"name":"pcn","description":"PCN scan + management"},{"name":"dvla","description":"DVLA vehicle enquiry"},{"name":"billing","description":"GoCardless subscription lifecycle"},{"name":"webhooks","description":"Third-party webhook receivers"},{"name":"notifications","description":"Cron-triggered compliance emails"},{"name":"backup","description":"Cron-triggered database backup ops"},{"name":"partner","description":"Council Connector — partner API for parking-system vendors and councils"}],"paths":{"/api/health":{"get":{"tags":["health"],"summary":"Liveness probe","security":[],"responses":{"200":{"description":"Service is up"}}}},"/api/scan-pcn/health":{"get":{"tags":["health"],"summary":"OCR microservice liveness","security":[],"responses":{"200":{"description":"OCR service is up"}}}},"/api/consent":{"post":{"tags":["consent"],"summary":"Store cookie consent decision","security":[],"description":"Rate limit: 30/min per IP.","responses":{"200":{"description":"Consent stored (cookie set)"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/verify-captcha":{"post":{"tags":["consent"],"summary":"Verify Turnstile/hCaptcha token","security":[],"description":"Rate limit: 30/min per IP.","responses":{"200":{"description":"Token valid"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/user/profile":{"get":{"tags":["user"],"summary":"Get signed-in user profile summary","description":"Rate limit: 60/min per user.","responses":{"200":{"description":"Profile returned"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/onboarding":{"post":{"tags":["user"],"summary":"Complete onboarding flow","description":"Rate limit: 20/min per user.","responses":{"200":{"description":"Onboarding complete"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/scan-pcn":{"post":{"tags":["pcn"],"summary":"Server-side OCR fallback for PCN scanning","description":"Primary OCR path is on-device (WebAssembly Tesseract). This route is a fallback for devices that cannot run it. Rate limit: 10/min per user.","responses":{"200":{"description":"Extracted PCN fields returned"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/dvla":{"get":{"tags":["dvla"],"summary":"Look up a UK vehicle by registration","description":"Rate limit: 30/min per user. Data source: DVLA Vehicle Enquiry Service.","parameters":[{"name":"vrm","in":"query","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Vehicle enrichment returned"},"400":{"description":"Missing / invalid VRM"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/upload":{"post":{"tags":["pcn"],"summary":"Upload a PCN image (multipart)","description":"Rate limit: 60/min per user. Max 10 MB.","responses":{"200":{"description":"Upload accepted, storage URL returned"},"401":{"$ref":"#/components/responses/Unauthorized"},"413":{"description":"Payload too large"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/export":{"get":{"tags":["pcn"],"summary":"Export PCN records as CSV","description":"Rate limit: 10/min per user.","responses":{"200":{"description":"text/csv body"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/gdpr/export":{"get":{"tags":["gdpr"],"summary":"GDPR Article 15 — data subject access request","description":"Returns a JSON archive of all personal data across every user table. Rate limit: 10/min per user.","responses":{"200":{"description":"Archive returned"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/gdpr/delete":{"post":{"tags":["gdpr"],"summary":"GDPR Article 17 — request account erasure","description":"Queues a deletion request with a 30-day reversal window, then hard-deletes. Rate limit: 20/min per user.","responses":{"200":{"description":"Deletion queued"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/gdpr/delete/execute":{"post":{"tags":["gdpr"],"summary":"Cron: execute queued deletions past their reversal window","security":[{"CronSecret":[]}],"responses":{"200":{"description":"Deletions executed"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/gocardless/create-billing-request":{"post":{"tags":["billing"],"summary":"Start a GoCardless Direct Debit mandate flow for a tier","description":"Rate limit: 20/min per user.","requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"tier":{"type":"string","enum":["user","starter","pro","enterprise"]}}}}}},"responses":{"200":{"description":"Authorisation URL returned"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"description":"An active subscription already exists"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/gocardless/cancel":{"delete":{"tags":["billing"],"summary":"Cancel the current GoCardless subscription","description":"Rate limit: 20/min per user.","responses":{"200":{"description":"Subscription cancelled at period end"},"401":{"$ref":"#/components/responses/Unauthorized"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/gocardless/subscription-status":{"get":{"tags":["billing"],"summary":"Current subscription tier + status","responses":{"200":{"description":"Subscription state returned"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/webhooks/gocardless":{"post":{"tags":["webhooks"],"summary":"GoCardless event webhook (signature-verified)","security":[{"WebhookSignature":[]}],"description":"Idempotent event handler for mandates.activated, payments.confirmed / failed, subscriptions.cancelled and related. Rate limit: 120/min per IP.","responses":{"200":{"description":"Event(s) processed"},"429":{"$ref":"#/components/responses/RateLimited"},"498":{"$ref":"#/components/responses/InvalidSignature"}}}},"/api/webhooks/stripe":{"post":{"tags":["webhooks"],"summary":"Stripe event webhook (signature-verified)","security":[{"WebhookSignature":[]}],"description":"Handles checkout.session.completed, payment_intent.payment_failed, and charge.refunded. Idempotent via stripe_events. Rate limit: 120/min per IP.","responses":{"200":{"description":"Event processed"},"429":{"$ref":"#/components/responses/RateLimited"},"498":{"$ref":"#/components/responses/InvalidSignature"}}}},"/api/notifications/compliance":{"post":{"tags":["notifications"],"summary":"Cron: daily MOT/tax expiry email digest","security":[{"CronSecret":[]}],"responses":{"200":{"description":"Alerts sent"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/notifications/pcn-deadlines":{"post":{"tags":["notifications"],"summary":"Cron: PCN discount / representation deadline reminders","security":[{"CronSecret":[]}],"responses":{"200":{"description":"Reminders sent"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/notifications/weekly-digest":{"post":{"tags":["notifications"],"summary":"Cron: weekly fleet health digest","security":[{"CronSecret":[]}],"responses":{"200":{"description":"Digest sent"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/backup":{"post":{"tags":["backup"],"summary":"Cron: trigger encrypted Supabase backup to S3","security":[{"CronSecret":[]}],"responses":{"200":{"description":"Backup initiated"},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/api/partner/v1/ping":{"get":{"tags":["partner"],"summary":"Validate a partner API key","security":[{"PartnerApiKey":[]}],"description":"Returns partner identity, environment, granted scopes, and authorised council count. Rate limit: 300/min per key.","responses":{"200":{"description":"Key valid — partner context returned"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/partner/v1/events":{"post":{"tags":["partner"],"summary":"Push PCN lifecycle events (batch, idempotent)","security":[{"PartnerApiKey":[]}],"description":"Max 100 events per request. Each event carries a caller idempotency id, a monotone source_version per PCN, and a FULL current-state snapshot. Replays return the stored outcome; a changed payload under the same id yields idempotency_conflict; older versions return stale. Test-environment keys can never modify live PCN records. Requires scope pcn:write. Rate limits: 300 req/min and 2,000 events/min per key.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["events"],"properties":{"events":{"type":"array","maxItems":100,"items":{"type":"object","required":["id","type","council_id","pcn_reference","source_version","state"],"properties":{"id":{"type":"string","description":"Caller idempotency id — stable across retries"},"type":{"type":"string","enum":["pcn.issued","pcn.amount_updated","pcn.paid","pcn.cancelled","appeal.received","appeal.decided"]},"council_id":{"type":"string","format":"uuid"},"pcn_reference":{"type":"string"},"source_version":{"type":"integer","description":"Monotone per PCN — older versions are acknowledged as stale"},"occurred_at":{"type":"string","format":"date-time"},"vrm":{"type":"string","description":"Plaintext over TLS; hashed server-side, never persisted"},"state":{"type":"object","required":["status"],"properties":{"status":{"type":"string"},"amount_due_pence":{"type":"integer","minimum":0},"discount_deadline":{"type":"string","format":"date"}}}}}}}}}}},"responses":{"200":{"description":"Per-event results: processed | stale | duplicate-replay | idempotency_conflict | forbidden_council | invalid_event | error"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"422":{"description":"Batch shape invalid"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/partner/v1/pcns/verify":{"post":{"tags":["partner"],"summary":"Verify a PCN reference and subscribe to its events","security":[{"PartnerApiKey":[]}],"description":"Reference travels in the body — never in a URL. Returns whether the reference is known plus its public state; never exposes fleet identity. Marks the reference subscribed so the vendor continues sending events for it. Requires scope pcn:read.","responses":{"200":{"description":"{ known, status?, amount_due_pence?, discount_deadline?, source_version? }"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/api/backup/verify":{"post":{"tags":["backup"],"summary":"Cron: verify latest backup integrity","security":[{"CronSecret":[]}],"responses":{"200":{"description":"Verification passed"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"description":"Verification failed — check Sentry"}}}}}}