Skip to content

Errors and rate limits

Error response format

All error responses use HTTP 4xx/5xx with a consistent JSON body:

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "One or more fields failed validation.",
    "requestId": "req_01K1ABC123",
    "details": {
      "fieldErrors": [
        {
          "field": "dropoff.address.postalCode",
          "code": "INVALID_FORMAT",
          "message": "Must be a 5- or 9-digit US postal code."
        }
      ]
    }
  }
}

code is a stable, machine-readable enum — branch on it. message is human-readable and may change without notice; never parse it.

requestId matches the X-Request-Id header, which is present on every response including successes. Log it unconditionally, not just on errors — it is the only identifier OnPoint support needs to trace a call, and capturing it only on failure means you cannot investigate a request that returned 200 with the wrong result.

For validation failures, details.fieldErrors gives one entry per offending field with a JSON-path-style field pointer, so you can attach errors directly to form inputs rather than surfacing a single opaque string.

Error codes

CodeHTTPDescription
VALIDATION_ERROR400 / 422Request failed schema or business validation
INVALID_ADDRESS422Address is well-formed but could not be geocoded
INVALID_PHONE_NUMBER400Phone not valid E.164
INVALID_TIME_CONSTRAINT422Time window invalid, in the past, or unservable
INVALID_STORE_REFERENCE422Unknown storeReference
INVALID_TEAM_REFERENCE422Unknown dispatch.teamReference
UNSUPPORTED_REQUIREMENT422Verification mechanism, cargo attribute, or dispatch mode not enabled for your account
UNSUPPORTED_DESTINATION422Destination type cannot be served — most often an APO/FPO/DPO address, which is USPS-only
INSTALLATION_NOT_SERVED422OnPoint does not deliver to this installation
INSTALLATION_NOT_ACCEPTING_DELIVERIES422Installation is currently closed to commercial delivery
PROHIBITED_CARGO_FOR_DESTINATION422Declared package.contains includes cargo this installation prohibits
NO_ELIGIBLE_PROVIDERS422No carrier available to serve this delivery
NO_CREDENTIALED_DRIVER_AVAILABLE422No driver holding a current credential for this installation is available
QUOTES_NOT_SUPPORTED403Quoting not enabled for your account
QUOTE_EXPIRED409quoteId expired — request new quotes
DUPLICATE_ORDER409Active delivery with same clientOrderReference exists
IDEMPOTENCY_KEY_REUSED409Same Idempotency-Key sent with a different request body
IDEMPOTENCY_CONFLICT409A request with this Idempotency-Key is still in flight
DELIVERY_NOT_FOUND404Unknown deliveryId or reference
DRIVER_NOT_FOUND404Unknown driver id
INSTALLATION_NOT_FOUND404Unknown installation code
INVALID_STATE_TRANSITION409Operation not allowed in current status
SANDBOX_ONLY403Operation attempted with a live key — simulation is sandbox-only
UNAUTHORIZED401Missing or invalid API key
RATE_LIMIT_EXCEEDED429Too many requests
SERVICE_UNAVAILABLE503Temporary outage — retry with backoff
INTERNAL_ERROR500Unexpected error — retry idempotent reads

400 vs 422

The distinction is deliberate and consistent:

  • 400 — OnPoint could not understand the request. Malformed JSON, wrong types, missing required fields, a value that violates the schema. Retrying without changing the request will never succeed.
  • 422 — The request was understood and structurally valid, but it cannot be fulfilled. Unservable address, no carrier coverage, a pickup window that has already closed. The same request may succeed later or from a different store.

Treat 400 as a bug in your integration and 422 as a business outcome to surface to the operator.

No quotes available

POST /quotes returns 200 with an empty quotes array — not an error — when no carrier can serve the delivery. Check noQuoteReason for why (OUTSIDE_COVERAGE, NO_CAPACITY, OUTSIDE_HOURS, REQUIREMENT_UNSUPPORTED). An empty result set is a normal answer to a valid question, so it does not belong in your error path.

Not serviceable is also not an error

POST /serviceability follows the same principle: an unreachable stop returns 200 with serviceable: false and a reasonCode. Branch on the field, not the status code. When the blocker is temporal — a closed gate rather than a restricted post — retryAfter tells you when the answer will change.

HTTP status summary

StatusWhen
200Successful read, update, or replayed idempotent create
201Delivery created
400Malformed or schema-invalid input
401Authentication failure
403Feature not enabled for your account
404Resource not found
409State conflict (duplicate, expired quote, idempotency conflict, invalid transition)
422Understood but unfulfillable
429Rate limited
503Service unavailable

Rate limits

Default limit: 20 requests per second per API key (see capabilities.maxRequestsPerSecond).

Quota headers on every response

You do not have to wait for a 429 to find out where you stand. Every response carries your remaining quota:

HTTP/1.1 200 OK
X-Request-Id: req_01K1ABC123
RateLimit: "default";r=17;t=1
RateLimit-Policy: "default";q=20;w=1
X-RateLimit-Limit: 20
X-RateLimit-Remaining: 17
X-RateLimit-Reset: 1785102285

RateLimit and RateLimit-Policy follow the IETF rate-limit headers draft: r is requests remaining, t is seconds until reset, q is the quota, w is the window. The X-RateLimit-* headers carry the same information for clients already written against that older convention.

One trap worth calling out: X-RateLimit-Reset is a Unix epoch timestamp, while Retry-After is a delay in seconds. Treating one as the other produces either a stampede or an hours-long stall. Compute reset - now() if you use the former.

RateLimit-Policy is stable — read it once at startup rather than parsing it on every response.

When you are limited

HTTP/1.1 429 Too Many Requests
Retry-After: 2
RateLimit: "default";r=0;t=2
json
{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Retry after 2 seconds.",
    "requestId": "req_01K1RATE999"
  }
}

Guidance:

  • Honor Retry-After when present.
  • Use exponential backoff with jitter on 429 and 503.
  • Never retry 400, 403, or 422 — the same request will fail the same way.
  • After a write timeout, retry with the same Idempotency-Key (see below) rather than probing with a GET.
  • Prefer webhooks over polling for status — coalesce reads when possible.

Idempotency

Idempotency-Key is required on POST /deliveries and optional (but recommended) on POST /deliveries/{deliveryId}/cancel. Use a fresh UUIDv4 per logical operation — not per HTTP attempt.

SituationBehavior
First request with a keyExecuted normally. 201 Created.
Retry, same key and same bodyOriginal response replayed byte-for-byte with 200 and Idempotency-Replayed: true. No second delivery.
Retry, same key, different body409 IDEMPOTENCY_KEY_REUSED. Nothing is executed.
Two concurrent requests, same keyOne executes; the other gets 409 IDEMPOTENCY_CONFLICT. Retry after a short delay to get the replay.

Keys are retained for 24 hours. After that a reused key is treated as new — which is why clientOrderReference still exists as an independent duplicate guard.

Idempotency-Key vs clientOrderReference

These solve different problems and are not interchangeable:

  • Idempotency-Key protects a single HTTP call from being executed twice. Scope: one request, 24 hours.
  • clientOrderReference is your business identifier for the order. It is the lookup key for GET /deliveries?clientOrderReference={ref} and triggers 409 DUPLICATE_ORDER if an active delivery already uses it. Scope: the order's lifetime.

A retry storm needs the first. A double-submitted order form needs the second.

Safe retry after a network failure

1. POST /deliveries with Idempotency-Key: <uuid>  → timeout, outcome unknown
2. Retry POST with the SAME Idempotency-Key and the SAME body
   → 201  the original never landed; created now
   → 200 + Idempotency-Replayed: true  it did land; this is that response
   → 409 IDEMPOTENCY_CONFLICT  still in flight; wait ~1s and retry

No GET probe is needed. The retry itself is the safe operation, which is the whole point of the key.