Envelope and errors
Every Openhandle response uses one envelope. Every error has a code, a request ID, and a retry rule.
Every successful response wraps its data in the same envelope, and every error returns the same error shape with a machine-readable code and an explicit retry flag. You handle responses once, for every platform and every endpoint.
The success envelope
{
"platform": "instagram",
"resource": "profile",
"capturedAt": "2026-08-06T12:04:11Z",
"source": "live",
"data": { "…": "…" },
"meta": {
"cursors": { "next": "opaque-cursor" }
}
}| Field | Meaning |
|---|---|
platform | instagram, tiktok, twitter, or reddit |
resource | profile, post, comment, hashtag, location, music, category, list, or normalized extended entity. Reddit adds subreddit, rule, wiki, and trophy |
capturedAt | RFC 3339 timestamp of when the data was captured |
source | live (fetched now) or cache (served from an earlier capture) |
data | The normalized resource, or an array of resources for lists |
meta | Always on lists, with cursors.next and any page metadata. See pagination. A single object has meta only when the platform sends extra metadata. Its cursors.next is then always null |
Shared cores and platform schemas
Openhandle reuses schemas only when their meaning is stable across platforms.
For example, every post composes the shared PostCore, PostMetrics,
PostAuthor, PostMedia, and MediaReference components. The final response
is still an InstagramPost, TikTokPost, or TwitterPost, with a matching
platform metrics schema and platform-only relationships.
Check each endpoint schema for field presence and nullability. Measured zeroes remain 0.
Instagram profiles, comments, hashtags, and locations omit missing fields.
Platform fields remain useful when another platform lacks an equivalent. TikTok can
return metrics.downloads, while Twitter can return metrics.quotes,
metrics.bookmarks, quoted_post, and reposted_post. Media keeps a preferred
url for simple clients, lists mirror addresses of the same file in urls,
and exposes every usable public encoding in renditions when the upstream
response supplies alternatives.
Each endpoint owns its response schema. Shared names express shared meanings.
Every platform uses named groups such as metrics, verification, location,
and settings, without an extended wrapper. Each endpoint publishes a fixed
key set. A field the provider did not supply is null. Supplied false, zero,
empty text, and empty arrays remain as supplied. The answered object promises
its identifier, URL, profile handle, post timestamp, and media array; those
are never null. The same type reached through another object, such as a
post author or a quoted post, is a Reference schema whose identifying fields
may be null when the provider left them out.
Domain-specific endpoints use
dedicated schemas where the provider data is stable enough, for example
InstagramStory, InstagramHighlight, TikTokPlaylist, and
TikTokEffect. Heterogeneous search endpoints use discriminated search-result
nodes, and filter/type endpoints use typed option nodes. No published operation
falls back to the generic Entity schema. See the generated OpenAPI document
for the exact schema attached to each operation.
The error envelope
{
"error": {
"code": "PROFILE_NOT_FOUND",
"message": "This profile was not found.",
"requestId": "req_01j8…",
"retryable": false
}
}Always branch on code, not on message. Include requestId when you
contact support. When retryable is true, retry with backoff; when it is
false, retrying will not help.
Error catalog
| Code | Status | Retryable | Meaning |
|---|---|---|---|
UNAUTHENTICATED | 401 | No | A valid API key is required |
EMAIL_VERIFICATION_REQUIRED | 403 | No | Verify the workspace-owner email before using the API |
BALANCE_EMPTY | 402 | No | Add balance to continue |
BILLING_SUSPENDED | 402 | No | Billing for this workspace is suspended |
SECURITY_SUSPENDED | 403 | No | API access for this workspace is suspended |
PROFILE_NOT_FOUND | 404 | No | The profile does not exist |
PROFILE_PRIVATE | 403 | No | The profile is private |
PROFILE_BLOCKED | 451 | No | The owner of this profile asked us not to serve it. Posts and comments by this profile are left out of lists |
POST_NOT_FOUND | 404 | No | The post does not exist |
COMMENT_NOT_FOUND | 404 | No | The comment does not exist |
RESOURCE_NOT_FOUND | 404 | No | The hashtag, location, music, playlist, subreddit, or other resource does not exist |
INVALID_IDENTIFIER | 400 | No | The identifier is invalid, or this endpoint cannot read it |
INVALID_URL | 400 | No | The social URL is invalid or unsupported |
INVALID_QUERY | 400 | No | A search query is required. Send it as q |
INVALID_FRESHNESS | 400 | No | Freshness must be live, 24h, 7d, or 30d |
INVALID_SINCE | 400 | No | since must be an RFC 3339 timestamp |
INVALID_PARAMETER | 400 | No | A query parameter such as sort or type has a value the endpoint does not accept |
CURSOR_MISMATCH | 400 | No | The cursor does not belong to this request |
UPSTREAM_SWITCHED | 409 | No | The upstream source no longer accepts this cursor. Restart pagination from the first page |
RATE_LIMITED | 429 | Yes | Rate limit exceeded. Honor the Retry-After header |
SERVICE_UNAVAILABLE | 503 | Yes | The service is temporarily unavailable |
UPSTREAM_DEGRADED | 503 | Yes | The upstream platform is temporarily unavailable |
INTERNAL_ERROR | 500 | Yes | The request could not be completed |
ROUTE_NOT_FOUND | 404 | No | The API route does not exist |
METHOD_NOT_ALLOWED | 405 | No | The HTTP method is not allowed on this route |
Billing and errors
You pay for answers, not attempts. PROFILE_NOT_FOUND, PROFILE_PRIVATE, and
other definitive answers are billable: the API did the work and gave you a
usable result. Provider failures and internal errors are never charged.
PROFILE_BLOCKED is never charged either.