Skip to main content

Workly for Python

The Python SDK for Workly, the open-source HTTP task queue. Enqueue a task, and Workly delivers it to your endpoint over HTTP, retrying with backoff until it succeeds.

pip install workly          # the SDK
pip install "workly[cli]"   # the SDK plus the workly binary, for `workly dev`

Requires Python 3.11+. The only dependency is httpx. The cli extra adds workly-cli, the server binary (about 7 MB), which you usually want on dev machines and not in production images.

Quickstart

Start a local server with workly dev (SQLite, no auth, on :7337), then:

from workly import Workly

client = Workly()  # WORKLY_URL, else http://localhost:7337; WORKLY_API_KEY
task = client.tasks.enqueue(
    "emails",
    url="https://api.example.com/send-email",
    payload={"user_id": 123},
)
print(task.id, task.state)

Your endpoint receives a POST with the JSON payload. Any 2xx marks the task succeeded. A 5xx, 408, 429, timeout or connection error is retried with backoff. Delivery is at-least-once, so make handlers safe to run twice. Workly-Task-Id is stable across attempts and works as a deduplication key.

Verifying deliveries

Every delivery is signed: with the server's WORKLY_SIGNING_SECRET when you run Workly yourself, or with your workspace's secret on workly.run. Check the signature against the raw body:

from workly import SignatureVerificationError, verify_signature


# Flask
@app.post("/send-email")
def send_email():
    try:
        verify_signature(request.get_data(), request.headers)  # secret from WORKLY_SIGNING_SECRET
    except SignatureVerificationError:
        abort(401)
    ...


# FastAPI / Starlette
@app.post("/send-email")
async def send_email(request: Request):
    verify_signature(await request.body(), request.headers, secret)
    ...

By default, signatures older than 5 minutes are rejected (tolerance=). workly dev signs with workly-dev-secret. To test your handler without a server, use workly.sign(body, secret), which returns the headers Workly would send.

Hosted Workly

On workly.run, point the client at the hosted API and use the keys from the console:

export WORKLY_URL=https://api.workly.run
export WORKLY_API_KEY=...                # API keys page
export WORKLY_SIGNING_SECRET=whsec_...   # same page; each workspace has its own

Nothing else changes. Hosted workspaces have a request rate limit and a monthly task quota: requests over the rate limit are retried for you, and an enqueue over the quota raises PermissionDeniedError with the code quota_exceeded.

Tasks

from datetime import datetime, timedelta, timezone

client.tasks.enqueue(
    "emails",
    url="https://api.example.com/send-email",
    payload={"user_id": 123},  # JSON; or body="raw text", content_type="text/csv"
    method="POST",  # default
    headers={"X-Tenant": "acme"},  # sent with every delivery
    delay_seconds=60,  # or run_at=datetime(..., tzinfo=timezone.utc)
    idempotency_key="welcome-123",  # an existing task with this key is returned instead
    max_attempts=5,  # overrides the queue's setting
    timeout_seconds=10,  # likewise
)

# Up to 100 at once, all or nothing.
client.tasks.enqueue_many(
    "emails",
    [{"url": "https://api.example.com/send-email", "payload": {"user_id": i}} for i in range(100)],
)

task = client.tasks.get("task_01J9...")
for attempt in client.tasks.attempts(task.id):
    print(attempt.number, attempt.outcome, attempt.status_code, attempt.duration_ms)

client.tasks.retry(task.id)  # run now; replays a finished task
delivery = client.tasks.delivery(task.id)  # the signed request, to replay by hand
client.tasks.cancel(task.id)

# Newest first, across pages. All filters are optional.
for task in client.tasks.list(
    queue="emails", state="failed", created_after=datetime.now(timezone.utc) - timedelta(days=1)
):
    ...

Queues

Queues are created automatically on first enqueue. put sets a queue's configuration declaratively, which makes it easy to keep in code: settings you leave out are reset to their defaults.

client.queues.put(
    "emails",
    max_attempts=10,
    min_backoff_seconds=1,
    max_backoff_seconds=3600,
    backoff_multiplier=2,
    timeout_seconds=30,
    rate_per_second=50,
    max_concurrency=20,
)

queue = client.queues.get("emails")
print(queue.stats.due, queue.stats.oldest_due_at)

client.queues.pause("emails")  # stop delivering; enqueueing still works
client.queues.resume("emails")
client.queues.cancel_pending("emails")  # cancel pending tasks
client.queues.retry_failed("emails")  # replay failed tasks, e.g. after an outage
for group in client.queues.failures("emails"):  # failed tasks grouped by error
    print(group.count, group.error)
client.queues.retry_failed("emails", last_error="HTTP 503 Service Unavailable")  # replay one group
client.queues.activity("emails")  # succeeded/failed/cancelled in the last 7 days
client.queues.delete("emails")  # delete the queue and all its tasks

Schedules

A schedule calls a URL on a cron timetable; each call is a run, retried and recorded like a task. Schedules are separate from queues. put is declarative, like queues.put. See Schedules.

schedule = client.schedules.put(
    "nightly-report",
    cron="0 3 * * *",
    timezone="Europe/Stockholm",
    url="https://api.example.com/reports/nightly",
    payload={"kind": "daily"},
    max_attempts=3,
    overlap="skip",  # skip a slot while the previous run is still going
)
print(schedule.next_runs)  # the next five times

run = client.schedules.trigger("nightly-report")  # run it now
for run in client.schedules.runs("nightly-report", state="failed"):
    print(run.id, run.scheduled_for, run.last_error)
client.runs.attempts(run.id)
client.runs.retry(run.id)  # deliver it again, e.g. a failed or skipped run
client.schedules.pause("nightly-report")
client.schedules.resume("nightly-report")  # from the next scheduled time
client.schedules.delete("nightly-report")  # with all its runs

Runs are delivered with Workly-Run-Id, Workly-Schedule and Workly-Scheduled-For headers and the same signature, so verify_signature works unchanged.

Async

AsyncWorkly has the same methods, as coroutines:

from workly import AsyncWorkly

async with AsyncWorkly() as client:
    task = await client.tasks.enqueue("emails", url="...", payload={"user_id": 123})
    async for task in client.tasks.list(queue="emails"):
        ...

Errors

Exception When
BadRequestError 400, invalid request
AuthenticationError 401, missing or wrong API key
PermissionDeniedError 403, not allowed, or a hosted limit was reached (e.g. quota_exceeded)
NotFoundError 404, no such queue or task
ConflictError 409, e.g. retrying a running task
PayloadTooLargeError 413
RateLimitError 429, still rate limited after the retries
APIError base class of the above; has .status, .code (stable) and .message
APIConnectionError the server couldn't be reached

Requests are retried up to max_retries times (2 by default), honouring Retry-After. A 429 is always retried, since the server refused the request without doing anything. Connection errors and 502–504 are retried for requests that are safe to repeat: an enqueue only when it has an idempotency_key, or when the connection failed before the request was sent.

License

Apache-2.0

Metadata

Release files for workly 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for workly 0.1.0
File Size Uploaded
workly-0.1.0.tar.gz 22.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for workly 0.1.0
File Interpreter ABI Platform
workly-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 51.0 kB

Release files / workly-0.1.0.tar.gz

Download URL workly-0.1.0.tar.gz
Size 22.5 kB
Tags Source
SHA-256 checksum
How to use checksums
cfc535c1571e7101adebf1f72f69e9488a3e2754b075a428ec0436090a52508a
BLAKE2b-256 checksum
How to use checksums
09b96a2be68a13b973beb6b6ddf00f5ec3e6c7220cc0b7dff72ef0ed79e89986
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release files / workly-0.1.0-py3-none-any.whl

Download URL workly-0.1.0-py3-none-any.whl
Size 28.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ec50a2bb50c5a9c538a2190c239696dd7fff5db0e0647ca4effdc0555abccf9a
BLAKE2b-256 checksum
How to use checksums
0bd9f8d654be49f8a8bae5808dee298083001997d8560afd76dbb7b89dae48b6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 18, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page