Appearance
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
| Code | HTTP | Description |
|---|---|---|
VALIDATION_ERROR | 400 / 422 | Request failed schema or business validation |
INVALID_ADDRESS | 422 | Address is well-formed but could not be geocoded |
INVALID_PHONE_NUMBER | 400 | Phone not valid E.164 |
INVALID_TIME_CONSTRAINT | 422 | Time window invalid, in the past, or unservable |
INVALID_STORE_REFERENCE | 422 | Unknown storeReference |
INVALID_TEAM_REFERENCE | 422 | Unknown dispatch.teamReference |
UNSUPPORTED_REQUIREMENT | 422 | Verification mechanism, cargo attribute, or dispatch mode not enabled for your account |
UNSUPPORTED_DESTINATION | 422 | Destination type cannot be served — most often an APO/FPO/DPO address, which is USPS-only |
INSTALLATION_NOT_SERVED | 422 | OnPoint does not deliver to this installation |
INSTALLATION_NOT_ACCEPTING_DELIVERIES | 422 | Installation is currently closed to commercial delivery |
PROHIBITED_CARGO_FOR_DESTINATION | 422 | Declared package.contains includes cargo this installation prohibits |
NO_ELIGIBLE_PROVIDERS | 422 | No carrier available to serve this delivery |
NO_CREDENTIALED_DRIVER_AVAILABLE | 422 | No driver holding a current credential for this installation is available |
QUOTES_NOT_SUPPORTED | 403 | Quoting not enabled for your account |
QUOTE_EXPIRED | 409 | quoteId expired — request new quotes |
DUPLICATE_ORDER | 409 | Active delivery with same clientOrderReference exists |
IDEMPOTENCY_KEY_REUSED | 409 | Same Idempotency-Key sent with a different request body |
IDEMPOTENCY_CONFLICT | 409 | A request with this Idempotency-Key is still in flight |
DELIVERY_NOT_FOUND | 404 | Unknown deliveryId or reference |
DRIVER_NOT_FOUND | 404 | Unknown driver id |
INSTALLATION_NOT_FOUND | 404 | Unknown installation code |
INVALID_STATE_TRANSITION | 409 | Operation not allowed in current status |
SANDBOX_ONLY | 403 | Operation attempted with a live key — simulation is sandbox-only |
UNAUTHORIZED | 401 | Missing or invalid API key |
RATE_LIMIT_EXCEEDED | 429 | Too many requests |
SERVICE_UNAVAILABLE | 503 | Temporary outage — retry with backoff |
INTERNAL_ERROR | 500 | Unexpected 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
| Status | When |
|---|---|
200 | Successful read, update, or replayed idempotent create |
201 | Delivery created |
400 | Malformed or schema-invalid input |
401 | Authentication failure |
403 | Feature not enabled for your account |
404 | Resource not found |
409 | State conflict (duplicate, expired quote, idempotency conflict, invalid transition) |
422 | Understood but unfulfillable |
429 | Rate limited |
503 | Service 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: 1785102285RateLimit 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=2json
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Rate limit exceeded. Retry after 2 seconds.",
"requestId": "req_01K1RATE999"
}
}Guidance:
- Honor
Retry-Afterwhen present. - Use exponential backoff with jitter on
429and503. - Never retry
400,403, or422— the same request will fail the same way. - After a write timeout, retry with the same
Idempotency-Key(see below) rather than probing with aGET. - 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.
| Situation | Behavior |
|---|---|
| First request with a key | Executed normally. 201 Created. |
| Retry, same key and same body | Original response replayed byte-for-byte with 200 and Idempotency-Replayed: true. No second delivery. |
| Retry, same key, different body | 409 IDEMPOTENCY_KEY_REUSED. Nothing is executed. |
| Two concurrent requests, same key | One 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-Keyprotects a single HTTP call from being executed twice. Scope: one request, 24 hours.clientOrderReferenceis your business identifier for the order. It is the lookup key forGET /deliveries?clientOrderReference={ref}and triggers409 DUPLICATE_ORDERif 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 retryNo GET probe is needed. The retry itself is the safe operation, which is the whole point of the key.