{"openapi":"3.1.0","info":{"title":"EVRegistry API","version":"1.0.0","summary":"The neutral EV-charger registration registry.","description":"A shared, neutral registry of EV-charger registrations. Anonymous lookups return minimal disclosure (registered yes\/no, count, period) and never reveal an organization's identity. Authenticated participants register chargers, manage the registration lifecycle, detect conflicts on overlapping claims, open and resolve disputes, and receive signed webhooks. The registry records dispute outcomes; it never decides ownership or stores charging\/energy data."},"servers":[{"url":"\/","description":"Same origin as this documentation."}],"tags":[{"name":"Lookup","description":"Public and authenticated charger lookup."},{"name":"Registrations","description":"Register chargers and manage the registration lifecycle."},{"name":"Disputes","description":"Open, message, withdraw, review and resolve disputes."},{"name":"OAuth","description":"Machine-to-machine token issuance."},{"name":"Webhooks","description":"Manage webhook endpoints and inspect deliveries."}],"components":{"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","description":"An API key (or an OAuth2 access token) presented as `Authorization: Bearer <token>`. Keys carry scopes such as `lookup`, `registrations:write`, `disputes:write`, `disputes:resolve`, and `webhooks:manage`."}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","examples":["conflict","invalid_transition","invalid_client"]},"message":{"type":"string"}},"required":["code","message"]}}},"Period":{"type":"object","properties":{"start":{"type":"string","format":"date","examples":["2026-01-01"]},"end":{"type":"string","format":"date","examples":["2026-12-31"]}},"required":["start","end"]},"PublicLookupResult":{"type":"object","description":"Anonymous minimal disclosure. NEVER includes an organization's identity.","properties":{"registered":{"type":"boolean"},"registration_count":{"type":"integer","examples":[1]},"active_period":{"oneOf":[{"$ref":"#\/components\/schemas\/Period"},{"type":"null"}]}},"required":["registered","registration_count","active_period"]},"Charger":{"type":"object","properties":{"vendor_canonical":{"type":"string","examples":["EVDUTY"]},"serial_last4":{"type":"string","examples":["5555"]},"serial_hash":{"type":"string","description":"SHA-256 of the canonical vendor + normalized serial.","examples":["a1b2c3..."]}}},"Registration":{"type":"object","properties":{"public_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["pending","active","expired","cancelled"]},"program_name":{"type":"string","examples":["Spring Rebate"]},"period":{"$ref":"#\/components\/schemas\/Period"},"charger":{"$ref":"#\/components\/schemas\/Charger"}}},"Conflict":{"type":"object","properties":{"public_id":{"type":"string","format":"uuid"},"type":{"type":"string","examples":["overlap"]},"conflicting_registration":{"type":"object","properties":{"public_id":{"type":"string","format":"uuid"},"organization":{"type":"string","description":"Disclosed to the authenticated, accountable registering party only \u2014 never on the anonymous lookup."},"period":{"$ref":"#\/components\/schemas\/Period"}}}}},"Dispute":{"type":"object","properties":{"public_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["open","under_review","resolved","withdrawn"]}}}}},"paths":{"\/api\/v1\/lookup\/{serial_hash}":{"get":{"tags":["Lookup"],"summary":"Anonymous public lookup","description":"Returns minimal disclosure for a charger by its serial hash. No authentication. Rate-limited per IP and per hash. Never reveals an organization's identity.","parameters":[{"name":"serial_hash","in":"path","required":true,"description":"64-character lowercase hex SHA-256 of the canonical vendor + normalized serial.","schema":{"type":"string","pattern":"^[a-f0-9]{64}$"}}],"responses":{"200":{"description":"Minimal disclosure result.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/PublicLookupResult"}}}},"429":{"description":"Rate limit exceeded."}}}},"\/api\/v1\/lookup":{"post":{"tags":["Lookup"],"summary":"Authenticated full lookup","description":"Returns the entitled detail for a charger, including the registering organizations' names. Accepts EITHER a single `{vendor, serial}` pair (response below) OR a batch `{items: [{vendor, serial}, ...]}` of 1\u2013100 pairs, which returns `{results: [...]}` with one entry per input item (in order). Requires the `lookup` scope.","security":[{"apiKey":[]}],"requestBody":{"required":true,"content":{"application\/json":{"schema":{"oneOf":[{"type":"object","properties":{"vendor":{"type":"string","maxLength":120},"serial":{"type":"string","maxLength":255}},"required":["vendor","serial"]},{"type":"object","properties":{"items":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","properties":{"vendor":{"type":"string","maxLength":120},"serial":{"type":"string","maxLength":255}},"required":["vendor","serial"]}}},"required":["items"]}]}}}},"responses":{"200":{"description":"Entitled lookup result. Single-pair shape, or `{results: [...]}` for a batch request.","content":{"application\/json":{"schema":{"oneOf":[{"type":"object","properties":{"registered":{"type":"boolean"},"registration_count":{"type":"integer"},"registrations":{"type":"array","items":{"type":"object","properties":{"public_id":{"type":"string","format":"uuid"},"program_name":{"type":"string"},"organization":{"type":"string"},"period":{"$ref":"#\/components\/schemas\/Period"}}}}}},{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"vendor":{"type":"string"},"serial":{"type":"string"},"registered":{"type":"boolean"},"registration_count":{"type":"integer"},"registrations":{"type":"array","items":{"type":"object","properties":{"public_id":{"type":"string","format":"uuid"},"program_name":{"type":"string"},"organization":{"type":"string"},"period":{"$ref":"#\/components\/schemas\/Period"}}}}}}}}}]}}}},"401":{"description":"Missing or invalid API key.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/Error"}}}}}}},"\/api\/v1\/registrations":{"post":{"tags":["Registrations"],"summary":"Register a charger","description":"Creates a registration for a charger over a period and runs advisory conflict detection. Requires `registrations:write`.","security":[{"apiKey":[]}],"requestBody":{"required":true,"content":{"application\/json":{"schema":{"type":"object","properties":{"vendor":{"type":"string","maxLength":120},"serial":{"type":"string","maxLength":255},"program_name":{"type":"string","maxLength":255},"start_date":{"type":"string","format":"date"},"end_date":{"type":"string","format":"date"},"metadata":{"type":"object","additionalProperties":true},"on_conflict":{"type":"string","enum":["warn","fail","force"],"default":"warn"}},"required":["vendor","serial","program_name","start_date","end_date"]}}}},"responses":{"201":{"description":"Registration created (with any advisory conflicts).","content":{"application\/json":{"schema":{"type":"object","properties":{"registration":{"$ref":"#\/components\/schemas\/Registration"},"conflicts":{"type":"array","items":{"$ref":"#\/components\/schemas\/Conflict"}}}}}}},"409":{"description":"Overlapping registration when on_conflict=fail.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/Error"}}}}}},"get":{"tags":["Registrations"],"summary":"List your registrations","description":"Paginated list of the caller org's registrations, newest first. Optional `status` filter. Any valid key.","security":[{"apiKey":[]}],"parameters":[{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["pending","active","expired","cancelled"]}}],"responses":{"200":{"description":"A page of registrations.","content":{"application\/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#\/components\/schemas\/Registration"}},"meta":{"type":"object","properties":{"current_page":{"type":"integer"},"last_page":{"type":"integer"},"per_page":{"type":"integer"},"total":{"type":"integer"}}}}}}}}}}},"\/api\/v1\/registrations\/batch":{"post":{"tags":["Registrations"],"summary":"Batch register chargers","description":"Registers 1\u2013100 chargers in one call. Each item is processed independently (its own per-charger lock + transaction) so one item's conflict or failure never aborts the others. Returns HTTP 207 (Multi-Status): the batch was processed; each `results[]` entry carries its own `status` (`created`, `conflict`, or `failed`). A structurally invalid envelope (empty, >100, or a malformed item) is rejected with 422 and nothing is written. Re-submitting re-registers already-created items as advisory conflicts \u2014 resubmit only the failed indices. Requires `registrations:write`.","security":[{"apiKey":[]}],"requestBody":{"required":true,"content":{"application\/json":{"schema":{"type":"object","properties":{"registrations":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","properties":{"vendor":{"type":"string","maxLength":120},"serial":{"type":"string","maxLength":255},"program_name":{"type":"string","maxLength":255},"start_date":{"type":"string","format":"date"},"end_date":{"type":"string","format":"date"},"metadata":{"type":"object","additionalProperties":true},"on_conflict":{"type":"string","enum":["warn","fail","force"],"default":"warn"}},"required":["vendor","serial","program_name","start_date","end_date"]}}},"required":["registrations"]}}}},"responses":{"207":{"description":"Batch processed. Each item carries its own status.","content":{"application\/json":{"schema":{"type":"object","properties":{"results":{"type":"array","items":{"type":"object","properties":{"index":{"type":"integer"},"status":{"type":"string","enum":["created","conflict","failed"]},"registration":{"$ref":"#\/components\/schemas\/Registration"},"conflicts":{"type":"array","items":{"$ref":"#\/components\/schemas\/Conflict"}},"error":{"type":"string","description":"Present only when status=failed."}}}},"summary":{"type":"object","properties":{"total":{"type":"integer"},"created":{"type":"integer"},"conflict":{"type":"integer"},"failed":{"type":"integer"}}}}}}}},"422":{"description":"Malformed envelope (empty, >100 items, or an invalid item) \u2014 nothing written.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/Error"}}}}}}},"\/api\/v1\/registrations\/{public_id}":{"get":{"tags":["Registrations"],"summary":"Show a registration","description":"One owned registration with its conflicts. A registration owned by another org is an indistinguishable 404.","security":[{"apiKey":[]}],"parameters":[{"name":"public_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The registration.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/Registration"}}}},"404":{"description":"Not found (or not owned)."}}},"patch":{"tags":["Registrations"],"summary":"Update mutable fields","description":"Edits only `program_name` and `metadata`. Charger identity and period are immutable here (a date change is a renew). Requires `registrations:write`.","security":[{"apiKey":[]}],"parameters":[{"name":"public_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application\/json":{"schema":{"type":"object","properties":{"program_name":{"type":"string","maxLength":255},"metadata":{"type":"object","additionalProperties":true}}}}}},"responses":{"200":{"description":"The updated registration.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/Registration"}}}}}}},"\/api\/v1\/registrations\/{public_id}\/cancel":{"post":{"tags":["Registrations"],"summary":"Cancel a registration","description":"Cancels an active\/pending registration. A terminal status is a 409 invalid_transition. Requires `registrations:write`.","security":[{"apiKey":[]}],"parameters":[{"name":"public_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The cancelled registration.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/Registration"}}}},"409":{"description":"Invalid transition.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/Error"}}}}}}},"\/api\/v1\/registrations\/{public_id}\/renew":{"post":{"tags":["Registrations"],"summary":"Renew a registration","description":"Creates a NEW registration for the same charger, org and program over a new period (the source row is never mutated). Re-runs conflict detection. Requires `registrations:write`.","security":[{"apiKey":[]}],"parameters":[{"name":"public_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application\/json":{"schema":{"type":"object","properties":{"start_date":{"type":"string","format":"date"},"end_date":{"type":"string","format":"date"},"on_conflict":{"type":"string","enum":["warn","fail","force"],"default":"warn"}},"required":["start_date","end_date"]}}}},"responses":{"201":{"description":"The renewed (new) registration with any conflicts.","content":{"application\/json":{"schema":{"type":"object","properties":{"registration":{"$ref":"#\/components\/schemas\/Registration"},"conflicts":{"type":"array","items":{"$ref":"#\/components\/schemas\/Conflict"}}}}}}}}}},"\/api\/v1\/disputes":{"post":{"tags":["Disputes"],"summary":"Open a dispute","description":"Opens a dispute over a conflict the caller org is a party to. Requires `disputes:write`. A conflict the caller isn't party to is a 404.","security":[{"apiKey":[]}],"requestBody":{"required":true,"content":{"application\/json":{"schema":{"type":"object","properties":{"conflict":{"type":"string","description":"The conflict public_id.","format":"uuid"},"reason":{"type":"string","maxLength":5000}},"required":["conflict","reason"]}}}},"responses":{"201":{"description":"Dispute opened.","content":{"application\/json":{"schema":{"type":"object","properties":{"dispute":{"$ref":"#\/components\/schemas\/Dispute"}}}}}},"409":{"description":"A dispute already exists, or invalid transition.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/Error"}}}}}},"get":{"tags":["Disputes"],"summary":"List disputes","description":"Disputes the caller org is a party to. Requires `disputes:write`.","security":[{"apiKey":[]}],"responses":{"200":{"description":"Disputes."}}}},"\/api\/v1\/disputes\/{public_id}":{"get":{"tags":["Disputes"],"summary":"Show a dispute","description":"A dispute the caller org is a party to (else 404).","security":[{"apiKey":[]}],"parameters":[{"name":"public_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The dispute.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/Dispute"}}}}}}},"\/api\/v1\/disputes\/{public_id}\/messages":{"post":{"tags":["Disputes"],"summary":"Post a dispute message","description":"Adds a message to an active dispute. Requires `disputes:write`.","security":[{"apiKey":[]}],"parameters":[{"name":"public_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application\/json":{"schema":{"type":"object","properties":{"body":{"type":"string","maxLength":5000}},"required":["body"]}}}},"responses":{"201":{"description":"Message posted."}}}},"\/api\/v1\/disputes\/{public_id}\/withdraw":{"post":{"tags":["Disputes"],"summary":"Withdraw a dispute","description":"The opener withdraws their dispute. Requires `disputes:write`.","security":[{"apiKey":[]}],"parameters":[{"name":"public_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Dispute withdrawn."}}}},"\/api\/v1\/disputes\/{public_id}\/review":{"post":{"tags":["Disputes"],"summary":"Move a dispute under review (registry operator)","description":"Operator-only. Requires the `disputes:resolve` scope. Not org-scoped.","security":[{"apiKey":[]}],"parameters":[{"name":"public_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Dispute under review."}}}},"\/api\/v1\/disputes\/{public_id}\/resolve":{"post":{"tags":["Disputes"],"summary":"Resolve a dispute (registry operator)","description":"Operator-only. RECORDS the outcome the parties reached; never assigns ownership or credit. Requires `disputes:resolve`.","security":[{"apiKey":[]}],"parameters":[{"name":"public_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application\/json":{"schema":{"type":"object","properties":{"resolution_note":{"type":"string","maxLength":5000}},"required":["resolution_note"]}}}},"responses":{"200":{"description":"Dispute resolved."}}}},"\/api\/v1\/oauth\/token":{"post":{"tags":["OAuth"],"summary":"Issue an access token (client credentials)","description":"Exchanges a client_id + client_secret for a short-lived (1 hour) opaque Bearer token. The token plaintext is returned once. Unauthenticated body; throttled.","requestBody":{"required":true,"content":{"application\/json":{"schema":{"type":"object","properties":{"grant_type":{"type":"string","enum":["client_credentials"]},"client_id":{"type":"string"},"client_secret":{"type":"string"}},"required":["grant_type","client_id","client_secret"]}}}},"responses":{"200":{"description":"An access token.","content":{"application\/json":{"schema":{"type":"object","properties":{"access_token":{"type":"string"},"token_type":{"type":"string","examples":["Bearer"]},"expires_in":{"type":"integer","examples":[3600]},"scope":{"type":"string"}}}}}},"400":{"description":"Unsupported grant type.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/Error"}}}},"401":{"description":"Client authentication failed.","content":{"application\/json":{"schema":{"$ref":"#\/components\/schemas\/Error"}}}}}}},"\/api\/v1\/webhooks\/endpoints":{"post":{"tags":["Webhooks"],"summary":"Create a webhook endpoint","description":"Registers a public HTTPS URL to receive signed event deliveries. The signing secret is returned ONLY here. Requires `webhooks:manage`. Each delivery carries an `EVR-Signature: t={ts},v1={hmac}` header (HMAC-SHA256 of `{ts}.{body}`).","security":[{"apiKey":[]}],"requestBody":{"required":true,"content":{"application\/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri","maxLength":2048},"events":{"type":"array","items":{"type":"string","enum":["registration.created","registration.updated","registration.expired","registration.cancelled","conflict.detected","dispute.created","dispute.resolved"]}}},"required":["url","events"]}}}},"responses":{"201":{"description":"Endpoint created. Includes the signing secret (once).","content":{"application\/json":{"schema":{"type":"object","properties":{"id":{"type":"integer"},"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string"}},"status":{"type":"string","examples":["active"]},"secret":{"type":"string","description":"Returned only at creation."}}}}}}}},"get":{"tags":["Webhooks"],"summary":"List webhook endpoints","description":"The caller org's endpoints. The signing secret is never included. Requires `webhooks:manage`.","security":[{"apiKey":[]}],"responses":{"200":{"description":"Endpoints."}}}},"\/api\/v1\/webhooks\/endpoints\/{id}":{"get":{"tags":["Webhooks"],"summary":"Show a webhook endpoint","security":[{"apiKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"The endpoint."},"404":{"description":"Not found (or not owned)."}}},"patch":{"tags":["Webhooks"],"summary":"Update a webhook endpoint","description":"Change the URL, events, or status. The secret is never returned. Requires `webhooks:manage`.","security":[{"apiKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"The updated endpoint."}}},"delete":{"tags":["Webhooks"],"summary":"Delete a webhook endpoint","security":[{"apiKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Deleted."}}}},"\/api\/v1\/webhooks\/endpoints\/{id}\/deliveries":{"get":{"tags":["Webhooks"],"summary":"List deliveries for an endpoint","description":"The delivery log for an endpoint. Requires `webhooks:manage`.","security":[{"apiKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Deliveries."}}}},"\/api\/v1\/webhooks\/deliveries\/{id}\/resend":{"post":{"tags":["Webhooks"],"summary":"Resend a delivery","description":"Re-dispatches a single delivery. Requires `webhooks:manage`.","security":[{"apiKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Re-dispatched."}}}}}}