Errors
Every v1 error response shares the same envelope — modelled after
Stripe's. The error always lives under error, never at the root, so
your client can distinguish error responses from successful ones by
shape alone.
{
"error": {
"type": "provider_error",
"code": "meter_blocked",
"message": "This meter has been blocked by the provider and cannot purchase electricity. Please contact BPC.",
"param": null,
"retryable": false,
"doc_url": "https://developers.scratchpower.com/developers/reference/errors#meter_blocked",
"request_id": "req_d1f1c2a4f6b94c2390b8c6a8f7d9e0e1"
}
}| Field | Type | Notes |
|---|---|---|
type | string | Broad category — branch on this for general handling (auth vs validation vs provider). See the Error types table below |
code | string | Specific, stable identifier — branch on this for surgical handling (meter_blocked vs purchase_amount_invalid) |
message | string | Human-readable, localised, safe to show to end users |
param | string | null | When the error is tied to a specific request field, this is the snake_case field name (e.g. subscriber_identifier). Useful for inline form-level errors |
retryable | boolean | true when the same request is safe to retry as-is. false when the caller must fix something before re-attempting |
doc_url | string | Deep-link to the documented code for support tickets |
request_id | string | Server-side correlation id (also returned in the X-Request-Id response header). Quote this when contacting support |
Every response — success or error — carries an X-Request-Id header.
When something goes wrong, the fastest path to a resolution is sending
us the request_id. You can also send your own correlation id in the
request X-Request-Id header (≤128 chars) and the server will echo it
back.
Branching strategy
Branch on type for broad handling, fall through to code for
surgical handling, and trust retryable for retry decisions:
async function purchase(payload) {
const res = await fetch("/v1/purchases/confirm", { method: "POST", body: JSON.stringify(payload) });
const json = await res.json();
if (!res.ok) {
const { type, code, message, param, retryable, request_id } = json.error;
if (type === "validation_error") return showFieldError(param, message);
if (type === "authentication_error") return refreshTokenAndRetry();
if (type === "insufficient_funds_error") return promptTopupBalance();
if (retryable) return scheduleRetryWithBackoff(payload, request_id);
return showFatalError(message, request_id);
}
return json;
}Error types
type | HTTP | When |
|---|---|---|
authentication_error | 401 | Token missing, expired, or invalid |
permission_error | 403 | Authenticated, but the actor lacks the required permission for this action |
validation_error | 400 | Request payload failed schema or business validation. Look at param for the offending field |
not_found_error | 404 | The referenced resource (account, operation, transaction) does not exist |
conflict_error | 409 | The request conflicts with current state (e.g. duplicate operation) |
insufficient_funds_error | 402 | The debited account has insufficient balance |
rate_limit_error | 429 | The caller has been rate-limited. Honour Retry-After |
provider_error | 422 | The downstream VAS provider rejected the request (bad meter, blocked subscriber, amount out of range) |
provider_timeout | 504 | The downstream provider didn't respond in time. State is unknown — see Handling timeouts |
provider_unavailable | 502 | The downstream provider is unreachable |
internal_error | 500 | Unexpected server-side error. Check request_id, contact support |
Codes
The full list of code values the platform emits today, grouped by
category. Branch on these in your client logic.
Authentication & permissions
code | type | retryable | Meaning |
|---|---|---|---|
invalid_credentials | authentication_error | false | Token missing, malformed, expired, or rejected. Refresh the OAuth token and re-attempt |
missing_permission | permission_error | false | The actor's role does not include the required permission |
forbidden | permission_error | false | The operation isn't allowed for this actor in the current state |
meter_not_allowed | permission_error | false | This meter isn't on your organisation's allow-list. param: subscriber_identifier |
retries_disabled | permission_error | false | Retries aren't enabled for this transaction — contact support to enable |
Validation
code | type | retryable | Meaning |
|---|---|---|---|
missing_field | validation_error | false | A required field was absent |
invalid_argument | validation_error | false | A field failed schema validation — param names it |
invalid_msisdn | validation_error | false | The phone number is not a valid MSISDN. param: beneficiary_phone_number |
invalid_meter_number | validation_error | false | The meter number isn't valid for the provider. param: subscriber_identifier |
invalid_amount | validation_error | false | Amount is malformed or non-positive |
amount_below_minimum | validation_error | false | Amount below the minimum vend. param: amount |
amount_above_maximum | validation_error | false | Amount above the maximum vend |
amount_below_charges | validation_error | false | Amount is less than the charges to apply |
method_not_allowed | validation_error | false | HTTP method is not supported on this endpoint |
Resource lookup
code | type | retryable | Meaning |
|---|---|---|---|
resource_not_found | not_found_error | false | No resource matched the id / reference |
meter_not_found | not_found_error | false | The provider doesn't recognise this meter number |
transaction_not_found | not_found_error | false | The retry lookup couldn't find a prior failed transaction |
Funds & state
code | type | retryable | Meaning |
|---|---|---|---|
insufficient_balance | insufficient_funds_error | false | The debited account doesn't have enough balance — message includes the gap |
duplicate_request | conflict_error | false | The provider rejected the request as a duplicate |
transaction_pending | conflict_error | true | A previous transaction on the same subscriber is still pending — wait and retry |
Provider-side
These are surfaced from the BPC integration. Most map cleanly from the
provider's response code; a handful disambiguate by message text. The
message field always echoes the provider's own wording so it's safe
to surface verbatim to end users.
code | type | retryable | Meaning |
|---|---|---|---|
meter_blocked | provider_error | false | The provider has blocked this meter from purchases (or marked it not-allowed-to-transact) |
meter_unsupported | validation_error | false | The meter type isn't supported (e.g. Ultima Plus meters can't have tokens issued for them) |
merchant_credit_exhausted | provider_unavailable | false | Our merchant balance with the provider is exhausted. Caller can't fix this — operations needs to top up |
provider_busy | provider_error | true | Provider returned a transient "cannot process right now" — safe to retry after backoff |
provider_error | provider_error | varies | Generic provider-side rejection. retryable: true when the provider hinted the failure was transient |
provider_timeout | provider_timeout | true | Upstream timeout — state is unknown, use retry rather than re-confirm |
provider_unavailable | provider_unavailable | true | The provider endpoint is unreachable (network failure, endpoint not listening, connection refused) |
upstream_error | provider_error | false | A non-auth upstream HTTP call failed |
Internal
code | type | retryable | Meaning |
|---|---|---|---|
internal_error | internal_error | false | Something broke server-side — quote request_id to support |
Handling timeouts
A provider_timeout (HTTP 504) means the platform reached the provider
but didn't get a response in time — the underlying transaction state
is unknown. The error will have retryable: true, but the right
recovery is not re-sending /confirm (which would risk a
double-charge). Instead, call
POST /v1/purchases/retry
with the same subscriber_identifier + external_reference and the
platform will ask the provider what actually happened.