Skip to content

Go SDK

Terminal window
go get chronos.sh/go
  • Requires Go 1.26 or later
  • Standard library only
  • There is no pull worker yet. Use push delivery and verify each delivery with Webhook.Verify, or claim pull jobs with the Node.js SDK worker.

Every exported type, field and constant is documented on pkg.go.dev.

import chronos "chronos.sh/go"
client, err := chronos.NewClient(os.Getenv("CHRONOS_API_KEY"))
if err != nil {
return err
}

The import needs the chronos name because the path ends in go. NewClient rejects an empty key; the API checks the key on the first request. To check it up front, call client.Account.Get(ctx), which returns the workspace the key belongs to. A Client is safe for concurrent use, so create one and share it.

Option Default Effect
WithBaseURL(url) https://api.chronos.sh Sends requests to another Chronos API, such as a local one. Must be an absolute http or https URL.
WithHTTPClient(c) A new http.Client Sends requests with your client. It is copied, and the copy never follows redirects. Its Timeout, if set, also bounds each attempt.
WithRequestTimeout(d) 30s Bounds each attempt, including reading the response.
WithMaxRequestRetries(n) 2 Sets the retries for requests that are safe to repeat. 0 turns retries off. See Retries.
WithUserAgent(token) none Adds a product token, such as my-app/1.2.0, ahead of chronos-go/<version>.

The examples below continue from this block: client and err exist, and ctx is your context.Context.

The client has one field per resource. Every method takes a context.Context first. Request and response fields use the API’s snake_case names in JSON, and the REST reference documents every field.

Method Request Returns
Create(ctx, params) POST /v1/schedules *Schedule
List(ctx, params) GET /v1/schedules *Page[*Schedule]
Iterate(ctx, params) GET /v1/schedules, every page iter.Seq2[*Schedule, error]
Get(ctx, id) GET /v1/schedules/{id} *Schedule
Update(ctx, id, params) PATCH /v1/schedules/{id} *Schedule
Delete(ctx, id) DELETE /v1/schedules/{id} error only
Pause(ctx, id) POST /v1/schedules/{id}/pause *Schedule
Resume(ctx, id) POST /v1/schedules/{id}/resume *Schedule
Trigger(ctx, id) POST /v1/schedules/{id}/trigger *Job
Archive(ctx, id) POST /v1/schedules/{id}/archive *Schedule

Param types: ScheduleCreateParams, ScheduleUpdateParams, ScheduleListParams.

Method Request Returns
Create(ctx, params) POST /v1/jobs *Job
List(ctx, params) GET /v1/jobs *Page[*Job]
Iterate(ctx, params) GET /v1/jobs, every page iter.Seq2[*Job, error]
Get(ctx, id) GET /v1/jobs/{id} *Job
Cancel(ctx, id) POST /v1/jobs/{id}/cancel *Job
Skip(ctx, id) POST /v1/jobs/{id}/skip *Job
Retry(ctx, id) POST /v1/jobs/{id}/retry *Job
Wait(ctx, id, opts) GET /v1/jobs/{id}, polled *Job

Param types: JobCreateParams, JobListParams, JobWaitOptions.

Jobs.Create is never retried after a 5xx or a network error, because the job may already exist. To retry it yourself, set an IdempotencyKey and treat ErrorCodeIdempotencyKeyConflict as success:

_, err = client.Jobs.Create(ctx, chronos.JobCreateParams{
Name: "Charge invoice inv_789",
Handler: new("charge-invoice"),
IdempotencyKey: new("charge-inv_789"),
})
// The job already exists, so a retried create can treat this as success.
if apiErr, ok := errors.AsType[*chronos.APIError](err); ok && apiErr.Code == chronos.ErrorCodeIdempotencyKeyConflict {
err = nil
}
if err != nil {
return err
}

Jobs.Wait polls the job until it is completed, failed, cancelled or skipped, and returns it. It polls every second unless you set JobWaitOptions.PollInterval. A failed job is returned, not an error, so check its Status. Wait stops at the deadline on ctx, or after 60 seconds if ctx has none, and returns a *WaitTimeoutError holding the last state it saw (nil if no request finished):

job, err := client.Jobs.Wait(ctx, jobID, chronos.JobWaitOptions{})
if err != nil {
return err
}
fmt.Println(job.Status)
Method Request Returns
List(ctx, params) GET /v1/executions *Page[*Execution]
Iterate(ctx, params) GET /v1/executions, every page iter.Seq2[*Execution, error]
Get(ctx, id) GET /v1/executions/{id} *Execution

Param type: ExecutionListParams. Set ScheduleID: chronos.Null[string]() to match executions of jobs without a schedule.

Method Request Returns
Get(ctx) GET /v1/account *Account

Optional request fields are pointers, and nil leaves the field out. Since Go 1.26, new(value) returns a pointer to a copy of the value:

_, err = client.Schedules.Create(ctx, chronos.ScheduleCreateParams{
Name: "Nightly report",
Cron: new("0 2 * * *"),
Timezone: new("Europe/London"),
MaxRetries: new(0), // sent as 0, not left out
Delivery: &chronos.Delivery{
Type: chronos.DeliveryTypePush,
HTTP: &chronos.HTTPDelivery{URL: "https://your-app.com/hooks/chronos", Method: "POST"},
},
})
if err != nil {
return err
}

ScheduleUpdateParams can also clear Description, Handler, Runs, StopAt and Payload. Those fields use chronos.Nullable. Its zero value leaves the field out, chronos.NewNullable(v) sets it, and chronos.Null[T]() sends null:

_, err = client.Schedules.Update(ctx, scheduleID, chronos.ScheduleUpdateParams{
Name: new("Nightly report (EU)"),
Runs: chronos.Null[int](), // remove the run limit
})
if err != nil {
return err
}

Timestamps are time.Time, and nullable response fields are pointers. Numbers inside Payload and Result decode as json.Number, with the exact digits the API sent. The API reads numbers as 64-bit floats, so send integers above 2^53 as strings.

List returns one page, newest first, with Data and Paging:

page, err := client.Jobs.List(ctx, chronos.JobListParams{Status: chronos.JobStatusFailed, Page: 2, Size: 50})
if err != nil {
return err
}
fmt.Println(len(page.Data), page.Paging.Total)

Iterate walks every page, fetching 100 items per request unless you set Size. Each item is yielded at most once, and breaking out of the loop stops further requests. Pages are offsets, so an item deleted or changed out of your filter during the walk shifts later items onto earlier pages, and Iterate can miss some of them.

That includes changes you make yourself. Retrying a failed job moves it out of JobStatusFailed, so collect the ids before acting on them:

var failed []string
for job, err := range client.Jobs.Iterate(ctx, chronos.JobListParams{Status: chronos.JobStatusFailed}) {
if err != nil {
return err
}
failed = append(failed, job.ID)
}
for _, id := range failed {
if _, err := client.Jobs.Retry(ctx, id); err != nil {
return err
}
}

The client retries a request only when repeating it is safe:

Failure GET POST, PATCH, DELETE
429 Retried Retried
5xx Retried Returned
Network error or timeout Retried Returned
Other 4xx Returned Returned

A 429 is safe to retry for any method, because the API rejects it before doing any work.

  • Waits back off from 0.5s, doubling up to 8s, each shortened by up to 25% of random jitter. A Retry-After header sets the wait instead. Above 60 seconds, the client returns the error without waiting.
  • A retry that would outlast the context’s deadline is skipped, and the last error is returned.
  • WithRequestTimeout bounds each attempt. A deadline on ctx bounds all attempts together.
  • Redirects are never followed, so a 3xx comes back as an *APIError.
Error When Check with
*APIError The API answered with a non-2xx status or "success": false errors.AsType[*chronos.APIError](err)
ErrUnauthorized, ErrNotFound, ErrRateLimited An *APIError with status 401, 404 or 429 errors.Is(err, chronos.ErrNotFound)
*NetworkError No usable response: DNS failure, refused or reset connection, timeout errors.AsType[*chronos.NetworkError](err)
ErrTimeout A *NetworkError for an attempt that timed out errors.Is(err, chronos.ErrTimeout)
ErrInvalidResponse A 2xx response without the documented shape errors.Is(err, chronos.ErrInvalidResponse)
ErrInvalidID An empty id, . or .., rejected before any request errors.Is(err, chronos.ErrInvalidID)
*WaitTimeoutError Jobs.Wait reached its deadline errors.AsType[*chronos.WaitTimeoutError](err)

*APIError carries StatusCode, Code, Message, RequestID, RetryAfter, RateLimit, FieldErrors and the raw Body. Branch on Code, using the ErrorCode constants. The API can add codes, so keep a default branch. See Error Codes for what each code means.

_, err = client.Schedules.Pause(ctx, scheduleID)
if apiErr, ok := errors.AsType[*chronos.APIError](err); ok && apiErr.Code == chronos.ErrorCodeScheduleAlreadyPaused {
err = nil // already paused
}
if err != nil {
return err
}

An ErrTimeout error also matches context.DeadlineExceeded. Cancelling your own context, or reaching its deadline, returns ctx.Err() instead, so check ctx.Err() to tell the two apart. Jobs.Wait is the exception. At a deadline it returns a *WaitTimeoutError, which also matches context.DeadlineExceeded.

Webhook.Verify checks the signature on push deliveries. Create the verifier with NewWebhook; it needs no client or API key. Pass every active signing secret during a rotation, and a delivery signed with any of them is accepted.

webhook, err := chronos.NewWebhook([]string{os.Getenv("CHRONOS_SIGNING_SECRET")})
if err != nil {
log.Fatal(err)
}
http.HandleFunc("/hooks/chronos", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
if err := webhook.Verify(body, r.Header); err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// Decode body and run the job.
})

Verify checks the X-Chronos-Signature, X-Chronos-Timestamp and X-Chronos-Delivery-Id headers against the raw body. Every failure wraps ErrInvalidWebhook. A timestamp more than 5 minutes from now is rejected. To change the window, pass WithTimestampTolerance(d) to NewWebhook; 0 turns the check off and removes replay protection.

See Signature Verification for the signing scheme.