OCPI Integration Common Mistakes and Pitfalls: 11 to Avoid

Common OCPI integration mistakes and pitfalls (pagination, idempotency, timestamps, status codes) and the concrete fix for each, from real builds.

The OCPI spec is clear enough to build from. The data model is sensible and the messages are documented. What sinks an integration is operational: the latitude the spec leaves to implementers, and the failure modes that only appear once real traffic runs through your endpoints. If you need a refresher on what the protocol does and where it sits between roaming partners, see what OCPI is.

The pattern repeats. A first integration works fine against one partner, then breaks against the second because a quiet assumption (single-page results, trusted timestamps, exact-case identifiers) never got generalized. Below are eleven mistakes, each with the concrete fix. Use it as a checklist to test your design against, before your second partner finds the gaps for you.

1. Treating the Credentials handshake as one-time

The mistake: implementing the Credentials handshake as a one-time setup ritual, hard-coded into a deploy script.

Why it’s a problem: tokens need to rotate. Partners change their endpoints. New partners get onboarded. A one-shot script means every change becomes engineering work.

What to do instead: build a proper partner-management interface (UI or admin tool) that supports adding partners, rotating tokens, suspending and re-enabling partners, and viewing the current state of each partnership. Treat partners as first-class entities in your domain model. The token lifecycle here is the same one covered in OCPI security and authentication, and the rotation path is much cheaper to build before you need it than during an incident.

2. Not handling case sensitivity for identifiers

The mistake: storing party IDs and country codes with mixed case, or comparing them case-sensitively when matching.

Why it’s a problem: OCPI specifies uppercase for both, but nothing in the transport enforces it. A lowercase party_id parses exactly as well as an uppercase one, so code that expects an exact-case match drops those messages without raising anything.

What to do instead: normalize on ingest. Uppercase everything. Validate format strictly. When comparing, compare the normalized form. Add tests that exercise mixed-case inputs.

3. Ignoring pagination

The mistake: calling GET /locations once, taking the first page, and assuming you got everything.

Why it’s a problem: OCPI lets a server split any list response into pages and point to the next one with a Link header. Nothing else in the response announces that it is partial. Read only the first page and you get a plausible-looking subset of the catalogue, with no error to tell you the rest exists.

What to do instead: follow the Link header. Implement cursor-based iteration as a shared utility rather than per-call code. Log pages and items fetched on every sync, so a catalogue that quietly shrinks is visible the next morning. Parse the header defensively: it is a standard HTTP header, but a sender can emit a shape your library refuses.

4. Trusting timestamps blindly

The mistake: using the timestamps in incoming messages as ground truth for billing, ordering, or reconciliation.

Why it’s a problem: the spec does not pin where in the chain a timestamp is taken. It can be stamped at the charger, at the CPO backend, or at the gateway that translates into OCPI. Those are three different clocks with three different drifts, so the same session can carry meaningfully different times depending on which field you read.

What to do instead: record both the message timestamp AND your receipt timestamp. Use receipt timestamps for operational ordering (“which arrived first?”). Use message timestamps with skepticism. For billing, define a single authoritative timestamp source per session (usually the CPO’s session_start) and document it.

5. Polling when push would work (or vice versa)

The mistake: polling a partner’s /sessions endpoint every minute “to make sure you have the latest data.”

Why it’s a problem: OCPI is push-first for most modules. Sessions and CDRs come to you via the sender PUTing or POSTing to your endpoints. Polling on top of that doubles your load, may rate-limit you, and indicates a misunderstanding of the protocol.

What to do instead: implement the push receivers correctly. Use polling only for modules that are inherently pull (Locations are often pull-once-then-cache, for example). If you’re not receiving expected pushes, investigate why the push isn’t happening rather than papering over it with polls. If the push-versus-pull split across modules still feels fuzzy, OCPI pull vs push patterns (coming soon) walks through which direction each module uses and why.

6. Idempotency failures

The mistake: treating every received message as a new event, even if the same session_id or cdr_id has been seen before.

Why it’s a problem: senders retry. Networks have hiccups. The same Session update or CDR may arrive twice. Without idempotency, you might double-bill, double-count, or create inconsistent state.

What to do instead: deduplicate by (party_id, session_id) for sessions, by (party_id, cdr_id) for CDRs. Make your ingestion handler explicitly idempotent. Test with replays.

A safe ingestion path looks like this:

flowchart TD
    A[Message arrives] --> B{Seen this<br/>dedup key?}
    B -->|Yes| C[Return 200<br/>no-op]
    B -->|No| D[Validate and<br/>normalize]
    D -->|Invalid| E[Return OCPI<br/>StatusCode error]
    D -->|Valid| F[Upsert with<br/>unique constraint]
    F --> G[Return 200<br/>success]
    style C fill:#e8f5e9,stroke:#43a047
    style E fill:#ffebee,stroke:#e53935
    style G fill:#e8f5e9,stroke:#43a047

7. Inadequate error responses

The mistake: returning HTTP 500 with no body when something goes wrong on your side.

Why it’s a problem: the partner has no idea what happened, so they cannot debug it. Every unexplained 500 becomes a support ticket, and the diagnosis then happens over email between two teams instead of in your logs.

What to do instead: return OCPI’s StatusCode format with a meaningful status_message. Include enough detail for the partner to act (“invalid country_code: must be ISO 3166 alpha-2 uppercase”) without leaking internal state. Test your error paths in your CI.

8. Trusting the partner’s claimed timezone

The mistake: accepting datetime values in partner-local timezone.

Why it’s a problem: OCPI mandates UTC ISO 8601 datetimes, but nothing on the wire stops a sender from emitting a local time. The value still parses. It lands in your database looking valid and shifted by the offset you never applied, which is worse than a rejection because nothing flags it.

What to do instead: validate UTC strictly on ingest. Reject (with a clear error) any datetime that isn’t UTC. When generating, always emit UTC with a Z suffix. Tests should explicitly cover the bad cases.

9. Underestimating Locations data volume

The mistake: storing Locations data in a single big table with no indexing strategy, assuming it’s static.

Why it’s a problem: a hub aggregates every CPO behind it, so a single credentials exchange can hand you a catalogue far larger than any one CPO would send. Each Location nests EVSEs, which nest Connectors, plus opening hours, images and terms. Querying that without indexes is slow, and updating it (when a partner adds a connector or changes a tariff reference) needs careful diff handling. The Locations module data model is worth internalizing before you design the schema that has to hold it, because how partial PATCH updates flow through the hierarchy decides how you store it.

What to do instead: design a schema that supports efficient lookup by location ID, by geo-bounding-box, by connector type, by CPO. Use partial updates (EVSE- and Connector-level PATCH deltas) rather than full snapshots when possible. Have a strategy for “this location is no longer published” (soft delete, archive).

10. Not having a mock partner

The mistake: testing only against real partners.

Why it’s a problem: real partners aren’t always available. Their outages and quirks mask bugs in your own code, and you cannot run integration tests against them in CI.

What to do instead: build a mock partner, a small server that speaks OCPI well enough to round-trip every message your real code sends. Run it in CI. When a real counterparty behaves oddly, reproduce that behaviour in the mock and keep the case there permanently. OCPI testing tools covers what belongs in one and where the line sits between mock and real-partner sandbox testing.

11. Trusting HTTP 200 instead of the OCPI status_code

The mistake: treating an HTTP 200 OK from a partner as confirmation that your request succeeded.

Why it’s a problem: OCPI wraps every response in an envelope carrying its own status_code, and the two layers are independent. A partner can return HTTP 200 while the envelope says 2001, meaning invalid or missing parameters: your call was received and rejected. If you only check the transport status, you record a failed PUT as a success, and the two sides quietly drift out of sync until a reconciliation surfaces the gap.

What to do instead: read the envelope on every response, not just the HTTP status. Anything in the 1xxx range is success; everything above it is not, and the range tells you whose problem it is and whether retrying can help. Work through OCPI error responses explained (coming soon) before you write the retry logic, because retrying a 2xxx you caused just repeats the same rejection. Log the status_message alongside the code: that free-text field is where partners explain what went wrong.

Bonus: the underrated investments

A few things that don’t make the “pitfall” list but consistently distinguish good OCPI integrations from mediocre ones.

Per-partner observability. Build dashboards that show, per partner: request volume, error rate, latency, last-successful-CDR-time. One row per party, and an alert when a row goes quiet. When something is wrong you see which partnership it belongs to immediately; when everything is fine, you have evidence of that.

Idempotency at the database layer. Use unique constraints. Use upsert patterns. Don’t depend on application code alone to enforce uniqueness; let the database catch the duplicates you missed.

Schema validation in CI. Validate every outgoing payload against a schema in your test suite. Malformed data then fails your build rather than a partner’s parser, which is the difference between a red pipeline and a support thread.

Friendly retry logic. When a partner is unreachable, queue and retry with exponential backoff. Don’t retry forever (eventually give up and alert), but don’t fail-fast on a transient network blip either.

Documentation per partner. A simple internal doc per partner: who they are, their party_id and country_code, who to contact for support, known quirks. Saves enormous time when something breaks at 2am.

Clear ownership. Name the team accountable for answering “is OCPI working right now”, and give them the dashboards, logs and alerts to answer it. If the honest answer is that engineering and operations share it, check that someone is actually reading the alerts.

The shape of the work

Starting from zero, the order matters more than the calendar:

  1. Spec reading, design, basic data model.
  2. Credentials handshake, then Locations and Sessions, then the mock partner to exercise them.
  3. Tariffs, CDRs, Tokens and Commands, plus the auth model that spans them.
  4. First real-partner testing, and the bugs it surfaces.
  5. Hardening: error handling, retries, observability.
  6. Second partner, and a different set of bugs.

What sets the duration is not the protocol. It is how many modules are in scope and how long you wait for a partner with time to test against you. That wait is the part you control least, which is the argument for building the mock in step 2 rather than discovering you need it after step 4.

The first partner takes longest because you are building the integration and learning the protocol at the same time. The second exposes the parts of your implementation that were shaped around the first. The third goes quickly if you generalized what one and two taught you.

Failures here are silent by default

If you have engineering time for exactly one thing beyond protocol correctness, spend it on observability. The reason is structural rather than cultural. A partner that has stopped pushing CDRs looks identical, from your side, to a partner with no sessions to report. Nothing throws. Nothing retries. There is no exception in your logs, because from your code’s point of view nothing happened at all.

The gap surfaces when somebody reconciles the money, which can be weeks later, and by then those CDRs may be outside the window in which the partner will resend them. That asymmetry is what justifies the dashboards: absence of traffic is a signal, and it is the one failure mode your error handling structurally cannot catch.

Key takeaways

OCPI integration is harder than the spec suggests but easier than it feels in week three. The habits that matter:

  • Normalize identifiers on ingest, validate UTC strictly, and follow the Link header. Each of those three is a silent data-loss path when skipped.
  • Read the envelope status_code, never the HTTP status alone.
  • Enforce idempotency at the database layer, not only in application code.
  • Treat partners as first-class entities in your domain model, and build the mock partner and the token-rotation path before you need them.
  • Budget for the second partner finding a different set of bugs than the first.

One thing to settle before you write any of it: pin which OCPI version each partner speaks in that partner’s record, not globally in your codebase. Version 2.2 (March 2020) and 2.2.1 (June 2020) are distinct releases, and a partner on either can reasonably describe itself as running 2.2.x. The version history covers what actually differs between them. The integration you can still maintain in year three is the one where every partner-specific assumption lives in a partner record instead of an if-statement.

Quick check

Q1. A partner sends CDR datetimes in their local timezone instead of UTC. What is the correct response?
Q2. Why is polling a partner /sessions endpoint every minute usually a mistake?
Q3. Which timestamp should you use for operational ordering of incoming messages?
Q4. What is the main value of building a mock partner?
Q5. A partner returns HTTP 200 OK but the response envelope contains status_code 2001. What actually happened?

Frequently asked questions

How long does a first OCPI integration take?

Longer than the module list suggests, and the protocol is rarely what sets the clock. Count the modules in scope, then add the wait for a partner with time to test against you. That wait is the part you control least, which is the argument for building a mock partner early enough to run the protocol work in parallel with it rather than after it.

Why does an OCPI integration that passed testing still fail against a new partner?

Because the spec leaves latitude and each implementation fills it differently: optional fields, page sizes, and where in the chain a timestamp is taken. Anything your code inferred from the first partner becomes a bug when the second one chooses otherwise. The second integration, not the first, is what tells you whether your implementation generalizes.

Should I build OCPI in-house or use a vendor?

For a CPO or eMSP with enough partnerships for roaming to be material to revenue, in-house OCPI knowledge is essential even when a vendor does the bulk of the work, because you still own the debugging. For a smaller operator, a CSMS or hub that handles OCPI may be enough. For a vendor or hub operator, OCPI is the product, so you build it.

What is one underrated investment in OCPI engineering?

A mock partner that runs in your CI environment. Testing every change against a realistic OCPI counterparty, without booking time with a real one, removes the slowest dependency in the build. Start it early and treat it as a first-class part of the codebase rather than a test fixture.

Found this useful? Share it.