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.

Example
json
{
  "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"
  }
}
FieldTypeNotes
typestringBroad category — branch on this for general handling (auth vs validation vs provider). See the Error types table below
codestringSpecific, stable identifier — branch on this for surgical handling (meter_blocked vs purchase_amount_invalid)
messagestringHuman-readable, localised, safe to show to end users
paramstring | nullWhen 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
retryablebooleantrue when the same request is safe to retry as-is. false when the caller must fix something before re-attempting
doc_urlstringDeep-link to the documented code for support tickets
request_idstringServer-side correlation id (also returned in the X-Request-Id response header). Quote this when contacting support
Always log request_id

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:

Node
js
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

typeHTTPWhen
authentication_error401Token missing, expired, or invalid
permission_error403Authenticated, but the actor lacks the required permission for this action
validation_error400Request payload failed schema or business validation. Look at param for the offending field
not_found_error404The referenced resource (account, operation, transaction) does not exist
conflict_error409The request conflicts with current state (e.g. duplicate operation)
insufficient_funds_error402The debited account has insufficient balance
rate_limit_error429The caller has been rate-limited. Honour Retry-After
provider_error422The downstream VAS provider rejected the request (bad meter, blocked subscriber, amount out of range)
provider_timeout504The downstream provider didn't respond in time. State is unknown — see Handling timeouts
provider_unavailable502The downstream provider is unreachable
internal_error500Unexpected 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

codetyperetryableMeaning
invalid_credentialsauthentication_errorfalseToken missing, malformed, expired, or rejected. Refresh the OAuth token and re-attempt
missing_permissionpermission_errorfalseThe actor's role does not include the required permission
forbiddenpermission_errorfalseThe operation isn't allowed for this actor in the current state
meter_not_allowedpermission_errorfalseThis meter isn't on your organisation's allow-list. param: subscriber_identifier
retries_disabledpermission_errorfalseRetries aren't enabled for this transaction — contact support to enable

Validation

codetyperetryableMeaning
missing_fieldvalidation_errorfalseA required field was absent
invalid_argumentvalidation_errorfalseA field failed schema validation — param names it
invalid_msisdnvalidation_errorfalseThe phone number is not a valid MSISDN. param: beneficiary_phone_number
invalid_meter_numbervalidation_errorfalseThe meter number isn't valid for the provider. param: subscriber_identifier
invalid_amountvalidation_errorfalseAmount is malformed or non-positive
amount_below_minimumvalidation_errorfalseAmount below the minimum vend. param: amount
amount_above_maximumvalidation_errorfalseAmount above the maximum vend
amount_below_chargesvalidation_errorfalseAmount is less than the charges to apply
method_not_allowedvalidation_errorfalseHTTP method is not supported on this endpoint

Resource lookup

codetyperetryableMeaning
resource_not_foundnot_found_errorfalseNo resource matched the id / reference
meter_not_foundnot_found_errorfalseThe provider doesn't recognise this meter number
transaction_not_foundnot_found_errorfalseThe retry lookup couldn't find a prior failed transaction

Funds & state

codetyperetryableMeaning
insufficient_balanceinsufficient_funds_errorfalseThe debited account doesn't have enough balance — message includes the gap
duplicate_requestconflict_errorfalseThe provider rejected the request as a duplicate
transaction_pendingconflict_errortrueA 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.

codetyperetryableMeaning
meter_blockedprovider_errorfalseThe provider has blocked this meter from purchases (or marked it not-allowed-to-transact)
meter_unsupportedvalidation_errorfalseThe meter type isn't supported (e.g. Ultima Plus meters can't have tokens issued for them)
merchant_credit_exhaustedprovider_unavailablefalseOur merchant balance with the provider is exhausted. Caller can't fix this — operations needs to top up
provider_busyprovider_errortrueProvider returned a transient "cannot process right now" — safe to retry after backoff
provider_errorprovider_errorvariesGeneric provider-side rejection. retryable: true when the provider hinted the failure was transient
provider_timeoutprovider_timeouttrueUpstream timeout — state is unknown, use retry rather than re-confirm
provider_unavailableprovider_unavailabletrueThe provider endpoint is unreachable (network failure, endpoint not listening, connection refused)
upstream_errorprovider_errorfalseA non-auth upstream HTTP call failed

Internal

codetyperetryableMeaning
internal_errorinternal_errorfalseSomething 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.