Docs / Guides
Error handling and error codes
The shape of Tend API errors, every error code with its meaning, and retry strategies that do not make things worse.
Last updated July 8, 2026
#Error format
Every non-2xx response from https://api.tendcomputer.com/v2 carries a JSON body with a single top-level error object. Three fields are always present: code, a stable machine-readable string; message, a human-readable explanation intended for logs; and request_id, which you should include in any support request. Validation errors add a details array listing each offending field.
{
"error": {
"code": "invalid_request",
"message": "Request validation failed.",
"request_id": "req_01JB5K8WQ2X7DT4N9YZ3MHRE6P",
"details": [
{"field": "target", "issue": "must be an https URL"},
{"field": "retry.max_attempts", "issue": "must be between 1 and 25"}
]
}
}Branch on code, not on message and not solely on the HTTP status. Messages are edited for clarity from time to time; codes are part of the API contract for a given api_version (currently 2026-03-01).
#Error code reference
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_request | The request body or query parameters could not be parsed or failed validation. The details field lists the offending fields. |
| 400 | invalid_cron_expression | The cron expression is malformed, or the timezone is not a valid IANA zone name. |
| 401 | missing_api_key | No Authorization header was sent, or it was not in the form Bearer <key>. |
| 401 | invalid_api_key | The key does not exist, has been revoked, or does not begin with tnd_live_ or tnd_dev_. |
| 403 | insufficient_scope | The key is valid but lacks the scope required for this operation, such as writing schedules with a read-only key. |
| 404 | resource_not_found | The job, schedule, or run ID does not exist in this project and environment. Keys from tnd_dev_ and tnd_live_ cannot see each other's resources. |
| 409 | idempotency_conflict | An Idempotency-Key was reused within 24 hours with a different request body. |
| 409 | schedule_name_taken | A schedule with this name already exists in the project. Schedule names are unique per environment. |
| 413 | payload_too_large | The job payload exceeds 256 KB after JSON encoding. |
| 422 | interval_too_short | The schedule would fire more often than once every 30 seconds, which is the minimum interval. |
| 422 | run_at_in_past | The requested run_at time is more than 60 seconds in the past. Small clock skew is tolerated; yesterday is not. |
| 429 | rate_limit_exceeded | The project exceeded its requests-per-minute limit. Retry after the number of seconds in the Retry-After header. |
| 429 | quota_exceeded | The monthly run quota for a Hobby project has been reached. Paid plans are billed for overage instead and never receive this error. |
| 503 | region_unavailable | The requested region is temporarily unable to accept new jobs. Retry, or omit region to route to the default. |
#Retryable versus permanent errors
Roughly speaking, 4xx means your request is wrong and 5xx means ours is. The exceptions matter, because they determine whether a retry helps or merely repeats a mistake at scale.
- Never retry unchanged:
invalid_request,invalid_cron_expression,missing_api_key,invalid_api_key,insufficient_scope,resource_not_found,payload_too_large,interval_too_short,run_at_in_past,schedule_name_taken. The same request will fail the same way. - Retry after waiting:
rate_limit_exceeded(honorRetry-After) andregion_unavailable(use backoff). - Retry only if you changed something:
idempotency_conflictmeans you reused a key with a different body. Fix the key or the body, not the timing. - Do not retry until the month rolls over or you upgrade:
quota_exceeded. Hobby projects have a hard cap of 10,000 runs per month, and no amount of backoff will create more.
#Idempotency
Networks fail in the least convenient place: after the server did the work but before you heard about it. To make retries safe, send an Idempotency-Key header on POST requests. If Tend has already processed a request with that key within the last 24 hours and the body is identical, it returns the original response instead of creating a second job. If the body differs, you receive 409 idempotency_conflict.
Choose keys that are stable for the logical operation, not random per attempt. A key derived from your own business identifier, such as welcome-email-usr_4471, survives process restarts. A fresh UUID generated inside the retry loop defeats the purpose entirely.
curl -X POST https://api.tendcomputer.com/v2/jobs \
-H "Authorization: Bearer $TEND_API_KEY" \
-H "Idempotency-Key: welcome-email-usr_4471" \
-H "Content-Type: application/json" \
-d '{"run_at": "2026-07-09T09:00:00Z", "target": "https://app.example-shop.dev/hooks/welcome", "payload": {"user": "usr_4471"}}'job = client.jobs.create(
run_at="2026-07-09T09:00:00Z",
target="https://app.example-shop.dev/hooks/welcome",
payload={"user": "usr_4471"},
idempotency_key="welcome-email-usr_4471",
)
print(job.id)const job = await client.jobs.create({
runAt: "2026-07-09T09:00:00Z",
target: "https://app.example-shop.dev/hooks/welcome",
payload: { user: "usr_4471" },
idempotencyKey: "welcome-email-usr_4471",
});
console.log(job.id);#Handling errors in code
The SDKs raise typed exceptions that expose code, status, request_id and, where present, details. Catch the specific classes you can act on and let everything else propagate. The following example retries only the errors that are worth retrying, using exponential backoff with full jitter, the same strategy Tend uses for job retries.
import random, time
from tend import Tend, TendError
client = Tend(api_key=os.environ["TEND_API_KEY"], max_retries=0)
RETRYABLE = {"rate_limit_exceeded", "region_unavailable"}
def create_job(params, attempts=6, base=1.0, cap=30.0):
for n in range(attempts):
try:
return client.jobs.create(**params)
except TendError as e:
if e.code not in RETRYABLE or n == attempts - 1:
raise
delay = e.retry_after or random.uniform(0, min(cap, base * 2 ** n))
time.sleep(delay)import { Tend, TendError } from "@tend/sdk";
const client = new Tend({ apiKey: process.env.TEND_API_KEY, maxRetries: 0 });
const RETRYABLE = new Set(["rate_limit_exceeded", "region_unavailable"]);
async function createJob(params, attempts = 6, base = 1, cap = 30) {
for (let n = 0; n < attempts; n++) {
try {
return await client.jobs.create(params);
} catch (err) {
if (!(err instanceof TendError) || !RETRYABLE.has(err.code) || n === attempts - 1) throw err;
const seconds = err.retryAfter ?? Math.random() * Math.min(cap, base * 2 ** n);
await new Promise((r) => setTimeout(r, seconds * 1000));
}
}
}func createJob(ctx context.Context, c *tend.Client, p *tend.JobParams) (*tend.Job, error) {
base, cap := 1.0, 30.0
for n := 0; n < 6; n++ {
job, err := c.Jobs.Create(ctx, p)
if err == nil {
return job, nil
}
var te *tend.Error
if !errors.As(err, &te) || (te.Code != "rate_limit_exceeded" && te.Code != "region_unavailable") || n == 5 {
return nil, err
}
wait := te.RetryAfter
if wait == 0 {
wait = time.Duration(rand.Float64()*math.Min(cap, base*math.Pow(2, float64(n)))*1000) * time.Millisecond
}
time.Sleep(wait)
}
return nil, errors.New("unreachable")
}#Job failures versus API errors
API errors describe problems with a request to Tend. A job failure is a different animal: it means a run started and your handler did not succeed, either by returning a non-2xx status, exceeding the 15-second webhook timeout, or running past the 15-minute maximum job runtime. Job failures never appear as HTTP errors on the API, because the API call that created the job succeeded long ago.
Failed runs are retried automatically, five attempts by default and up to 25 with retry.max_attempts. The delay before retry n is a random value between 0 and min(cap, base * 2^n), where base is 10 seconds and cap is 3600 seconds by default, and both can be overridden per job (the cap within 1 to 86400 seconds). Signed webhook deliveries of results are retried for up to 48 hours. Inspect any run with GET /runs/{id}, whose attempts array records the status code, latency and truncated response body of each try.
{
"id": "run_01JB6C3XN8Q4ZT7WK2DFY9MHAS",
"job_id": "job_01JB6C2R5V1E8GP4X0NTQ7YKDW",
"status": "retrying",
"attempt": 3,
"max_attempts": 5,
"next_attempt_at": "2026-07-08T14:22:51Z",
"attempts": [
{"n": 1, "status_code": 502, "latency_ms": 340},
{"n": 2, "status_code": 503, "latency_ms": 291},
{"n": 3, "status_code": null, "error": "timeout_15s", "latency_ms": 15000}
]
}#Getting help
When something looks wrong, collect the request_id from the error body (or the Tend-Request-Id response header) and the relevant job or run ID. Hobby projects can post in the community forum; Pro customers can write to support@tendcomputer.com and expect a response within one business day; Scale customers get a four-hour response target on business days. A request ID lets us find your exact request in seconds, which is considerably faster than a description of what you remember happening.