> **Note for Agents & LLMs**: This page has an LLM-friendly Markdown version. 
> For easier parsing and better context retention, use `/docs.md` instead of `/docs`.

# Documentation -- Jobs Done

Everything you need to integrate Jobs Done into your application. Choose your preferred method: CLI, SDK, or direct API calls.

## Contents

- [CLI](#cli)
- [SDKs](#sdks)
- [Webhooks](#webhooks)
- [OpenAPI Spec](#openapi)

---

## CLI

[LAPTOP] The `jobs_done` CLI provides full control from your terminal, CI pipeline, or LLM agent. Every command outputs clean JSON -- pipe it, parse it, automate it.

### Installation

#### Linux

```bash
# x86_64 (amd64)
curl -fsSL https://github.com/jobsdone/cli/releases/latest/download/jobs_done-linux-amd64 \
    -o /usr/local/bin/jobs_done && chmod +x /usr/local/bin/jobs_done

# arm64
curl -fsSL https://github.com/jobsdone/cli/releases/latest/download/jobs_done-linux-arm64 \
    -o /usr/local/bin/jobs_done && chmod +x /usr/local/bin/jobs_done

jobs_done --version
```

#### macOS

```bash
# Apple Silicon (arm64)
curl -fsSL https://github.com/jobsdone/cli/releases/latest/download/jobs_done-darwin-arm64 \
    -o /usr/local/bin/jobs_done && chmod +x /usr/local/bin/jobs_done

# Intel (amd64)
curl -fsSL https://github.com/jobsdone/cli/releases/latest/download/jobs_done-darwin-amd64 \
    -o /usr/local/bin/jobs_done && chmod +x /usr/local/bin/jobs_done

jobs_done --version
```

#### Windows (PowerShell)

```bash
# PowerShell -- run as Administrator
Invoke-WebRequest -Uri "https://github.com/jobsdone/cli/releases/latest/download/jobs_done-windows-amd64.exe" `
    -OutFile "$env:ProgramFiles\jobs_done.exe"

# Add to PATH if needed, then verify
jobs_done --version
```

### Quick Start

```bash
# Register and get your API key
jobs_done user register "My App"
# Output:
# Name:    My App
# API Key: jd_sk_8f3e...c21a

# Set your API key (or export JOBSDONE_API_KEY)
export JOBSDONE_API_KEY="your-api-key"

# Schedule a daily cron (fires at 09:00 UTC every day)
jobs_done cron create \
    --name "daily-report" \
    --cron "0 9 * * *" \
    --url "https://your-domain.com/webhooks/daily"

# Enqueue a one-off job (idempotent -- safe to call on every deploy)
jobs_done job enqueue \
    --id "send-welcome-email-user-123" \
    --url "https://your-domain.com/webhooks/welcome"

# List all your cron jobs
jobs_done cron list

# Pause and resume a cron job
jobs_done cron pause "daily-report"
jobs_done cron resume "daily-report"
```

### [BOT] LLM & agent-friendly

Every command outputs clean JSON -- perfect for piping into `jq`, scripting in CI, or driving from an AI agent. Pass `--output json` to suppress decorative output. The CLI reads `JOBSDONE_API_KEY` from the environment -- no interactive prompts in automation mode.

---

## SDKs

[SDK] Official SDKs for the Jobs Done API. Select your language below to see install instructions, authentication, and a complete code example.

| Language | Status | Package | Source |
|----------|--------|---------|--------|
| Node.js | Available | `npm install jobsdone-sdk` | [GitHub](https://github.com/bclaud/jobsdone-node) |
| Python | Coming soon | `pip install jobsdone-sdk` | -- |
| Ruby | Coming soon | `gem install jobs_done` | -- |
| Go | Coming soon | `go get jobsdone.dev/sdk/go` | -- |

### Node.js

Official Node.js/TypeScript SDK. Type-safe access to all endpoints with native `fetch`.

Source code: https://github.com/bclaud/jobsdone-node (MIT license, open-source)

#### Install

```bash
npm install jobsdone-sdk
```

#### Authentication

```bash
JOBSDONE_API_KEY=your-api-key
JOBSDONE_BASE_URL=https://jobsdone.claudlabs.com  # optional, defaults to https://jobsdone.claudlabs.com
```

#### Complete Example

This example demonstrates scheduling a daily report cron, enqueueing a one-off job, removing a tenant's schedules using a regex pattern, and handling a webhook.

```typescript
import { createClient } from 'jobsdone-sdk';

const client = createClient({
  apiKey: process.env.JOBSDONE_API_KEY!,
  baseUrl: process.env.JOBSDONE_BASE_URL, // optional, defaults to https://jobsdone.claudlabs.com
});

// -- 1. Schedule a daily report cron for a specific tenant ------------------
const tenantId = 'acme-corp';

const cron = await client.createCron({
  logicId: `daily-report-${tenantId}`,
  name: `Daily Report: ${tenantId}`,
  cronExpression: '0 9 * * *',           // 09:00 UTC every day
  webhookUrl: 'https://your-domain.com/webhooks/daily-report',
  body: {
    tenantId,
    reportType: 'daily-summary',
    format: 'json',
  },
  maxAttempts: 3,
});
console.log(`Cron scheduled: ${cron.logicId}`);

// -- 2. Enqueue a one-off welcome email job ---------------------------------
// Idempotent: calling this multiple times with the same logic_id is safe.
const job = await client.enqueue({
  logicId: `welcome-email-${tenantId}`,
  description: {
    webhook: 'https://your-domain.com/webhooks/welcome',
    userId: tenantId,
  },
});
console.log(`Job enqueued: id=${job.id}, state=${job.state}`);

// -- 3. Remove all schedules for a tenant using regex -----------------------
const allJobs = await client.listJobs();
const tenantJobs = allJobs.filter(
  (j) => j.logicId != null && j.logicId.startsWith(`daily-report-${tenantId}`)
);

for (const j of tenantJobs) {
  await client.cancelJob(j.logicId!);
  console.log(`Cancelled job: ${j.logicId}`);
}

// -- 4. Pause and resume a cron ----------------------------------------------
const paused = await client.pauseCron(`daily-report-${tenantId}`);
console.log(`Cron paused: active=${paused.active}`);

// Later, resume it:
const resumed = await client.resumeCron(`daily-report-${tenantId}`);
console.log(`Cron resumed: active=${resumed.active}`);
```

[KEY] **Authentication**: Set your API key via the `JOBSDONE_API_KEY` environment variable, or pass it directly to `createClient({ apiKey: 'your-key' })`.

---

### Python

Official Python SDK. Full type hints and synchronous httpx client.

#### Install

```bash
pip install jobsdone-sdk
```

#### Authentication

```bash
JOBSDONE_API_KEY=your-api-key
JOBSDONE_BASE_URL=https://jobsdone.claudlabs.com  # optional, defaults to https://jobsdone.claudlabs.com
```

#### Complete Example

```python
import os
from jobs_done import create_client, CronJobCreateRequest, EnqueueRequest

client = create_client({
    "api_key": os.environ["JOBSDONE_API_KEY"],
    "base_url": os.environ.get("JOBSDONE_BASE_URL", "https://jobsdone.claudlabs.com"),
})

# -- 1. Schedule a daily report cron for a specific tenant ------------------
tenant_id = "acme-corp"

cron = client.create_cron(CronJobCreateRequest(
    logic_id=f"daily-report-{tenant_id}",
    name=f"Daily Report: {tenant_id}",
    cron_expression="0 9 * * *",           # 09:00 UTC every day
    webhook_url="https://your-domain.com/webhooks/daily-report",
    body={
        "tenant_id": tenant_id,
        "report_type": "daily-summary",
        "format": "json",
    },
    max_attempts=3,
))
print(f"Cron scheduled: {cron.logic_id}")

# -- 2. Enqueue a one-off welcome email job ---------------------------------
# Idempotent: calling this multiple times with the same logic_id is safe.
result = client.enqueue(EnqueueRequest(
    logic_id=f"welcome-email-{tenant_id}",
    description={
        "webhook": "https://your-domain.com/webhooks/welcome",
        "user_id": tenant_id,
    },
))
print(f"Job enqueued: id={result.id}, state={result.state}")

# -- 3. Remove all schedules for a tenant using regex -----------------------
# Filter jobs in-memory; the API returns all jobs for the user.
all_jobs = client.list_jobs()
tenant_jobs = [j for j in all_jobs if j.logic_id and j.logic_id.startswith(f"daily-report-{tenant_id}")]

for job in tenant_jobs:
    client.cancel_job(job.logic_id)
    print(f"Cancelled job: {job.logic_id}")

# -- 4. Pause and resume a cron ----------------------------------------------
paused = client.pause_cron(f"daily-report-{tenant_id}")
print(f"Cron paused: active={paused.active}")

# Later, resume it:
resumed = client.resume_cron(f"daily-report-{tenant_id}")
print(f"Cron resumed: active={resumed.active}")
```

[KEY] **Authentication**: Set your API key via the `JOBSDONE_API_KEY` environment variable, or pass it directly to `create_client({"api_key": "your-key"})`.

---

### Ruby

Official Ruby SDK. Uses Faraday for HTTP and follows Ruby idioms.

#### Install

```bash
gem install jobs_done
```

#### Authentication

```bash
JOBSDONE_API_KEY=your-api-key
JOBSDONE_BASE_URL=https://jobsdone.claudlabs.com  # optional, defaults to https://jobsdone.claudlabs.com
```

#### Complete Example

```ruby
require 'jobs_done'

client = JobsDone.create_client(
  api_key: ENV['JOBSDONE_API_KEY'],
  base_url: ENV['JOBSDONE_BASE_URL'] || 'https://jobsdone.claudlabs.com'
)

# -- 1. Schedule a daily report cron for a specific tenant ------------------
tenant_id = 'acme-corp'

cron = client.create_cron(
  logic_id: "daily-report-#{tenant_id}",
  name: "Daily Report: #{tenant_id}",
  cron_expression: '0 9 * * *',           # 09:00 UTC every day
  webhook_url: 'https://your-domain.com/webhooks/daily-report',
  body: {
    tenant_id: tenant_id,
    report_type: 'daily-summary',
    format: 'json',
  },
  max_attempts: 3
)
puts "Cron scheduled: #{cron.logic_id}"

# -- 2. Enqueue a one-off welcome email job ---------------------------------
# Idempotent: calling this multiple times with the same logic_id is safe.
result = client.enqueue(
  logic_id: "welcome-email-#{tenant_id}",
  description: {
    webhook: 'https://your-domain.com/webhooks/welcome',
    user_id: tenant_id
  }
)
puts "Job enqueued: id=#{result.id}, state=#{result.state}"

# -- 3. Remove all schedules for a tenant using regex -----------------------
# Filter jobs in-memory; the API returns all jobs for the user.
all_jobs = client.list_jobs
tenant_jobs = all_jobs.select do |j|
  j.logic_id&.start_with?("daily-report-#{tenant_id}")
end

tenant_jobs.each do |job|
  client.cancel_job(job.logic_id)
  puts "Cancelled job: #{job.logic_id}"
end

# -- 4. Pause and resume a cron ----------------------------------------------
paused = client.pause_cron("daily-report-#{tenant_id}")
puts "Cron paused: active=#{paused.active}"

# Later, resume it:
resumed = client.resume_cron("daily-report-#{tenant_id}")
puts "Cron resumed: active=#{resumed.active}"
```

[KEY] **Authentication**: Set your API key via the `JOBSDONE_API_KEY` environment variable, or pass it directly to `JobsDone.create_client(api_key: 'your-key')`.

---

### Go

Official Go SDK. Idiomatic Go with context support and typed errors.

#### Install

```bash
go get jobsdone.dev/sdk/go
```

#### Authentication

```bash
export JOBSDONE_API_KEY=your-api-key
export JOBSDONE_BASE_URL=https://jobsdone.claudlabs.com  # optional, defaults to https://jobsdone.claudlabs.com
```

#### Complete Example

```go
package main

import (
	"context"
	"fmt"
	"os"
	"strings"

	jobsdone "jobsdone.dev/sdk/go"
)

func main() {
	client := jobsdone.NewClient(jobsdone.ClientConfig{
		APIKey:  os.Getenv("JOBSDONE_API_KEY"),
		BaseURL: os.Getenv("JOBSDONE_BASE_URL"), // optional, defaults to https://jobsdone.claudlabs.com
	})

	ctx := context.Background()
	tenantID := "acme-corp"

	// -- 1. Schedule a daily report cron for a specific tenant -------------
	cron, err := client.CreateCron(ctx, jobsdone.CronJobCreateRequest{
		LogicID:        fmt.Sprintf("daily-report-%s", tenantID),
		Name:           fmt.Sprintf("Daily Report: %s", tenantID),
		CronExpression: "0 9 * * *", // 09:00 UTC every day
		WebhookURL:     "https://your-domain.com/webhooks/daily-report",
		Body: map[string]any{
			"tenant_id":   tenantID,
			"report_type": "daily-summary",
			"format":      "json",
		},
		MaxAttempts: 3,
	})
	if err != nil {
		panic(err)
	}
	fmt.Printf("Cron scheduled: %s\n", cron.LogicID)

	// -- 2. Enqueue a one-off welcome email job -----------------------------
	// Idempotent: calling this multiple times with the same logic_id is safe.
	result, err := client.Enqueue(ctx, jobsdone.EnqueueRequest{
		LogicID:     fmt.Sprintf("welcome-email-%s", tenantID),
		Description: map[string]any{"webhook": "https://your-domain.com/webhooks/welcome", "user_id": tenantID},
	})
	if err != nil {
		panic(err)
	}
	fmt.Printf("Job enqueued: id=%d, state=%s\n", result.ID, result.State)

	// -- 3. Remove all schedules for a tenant using regex ------------------
	// The API supports SQL LIKE patterns via the logic_id query parameter.
	// A trailing "%" matches any suffix.
	jobs, err := client.ListJobs(ctx, &jobsdone.JobListOptions{
		LogicID: fmt.Sprintf("daily-report-%s%%", tenantID),
	})
	if err != nil {
		panic(err)
	}

	for _, job := range jobs {
		if job.LogicID == nil {
			continue
		}
		// The API already filtered by prefix, but we double-check:
		if !strings.HasPrefix(*job.LogicID, fmt.Sprintf("daily-report-%s", tenantID)) {
			continue
		}
		if err := client.CancelJob(ctx, *job.LogicID); err != nil {
			panic(err)
		}
		fmt.Printf("Cancelled job: %s\n", *job.LogicID)
	}

	// -- 4. Pause and resume a cron -----------------------------------------
	paused, err := client.PauseCron(ctx, fmt.Sprintf("daily-report-%s", tenantID))
	if err != nil {
		panic(err)
	}
	fmt.Printf("Cron paused: active=%v\n", paused.Active)

	// Later, resume it:
	resumed, err := client.ResumeCron(ctx, fmt.Sprintf("daily-report-%s", tenantID))
	if err != nil {
		panic(err)
	}
	fmt.Printf("Cron resumed: active=%v\n", resumed.Active)
}
```

[KEY] **Authentication**: Set your API key via the `JOBSDONE_API_KEY` environment variable, or pass it directly to `NewClient(ClientConfig{APIKey: "your-key"})`.

---

## Webhooks

[HOOK] When a job fires, Jobs Done sends an HTTP `POST` to your configured `webhook_url`. The request body contains the `body` you stored when creating the cron (or the `description` for one-off enqueued jobs).

### Webhook Request

```bash
# Example webhook request from Jobs Done to your endpoint:
POST /webhooks/daily-report HTTP/1.1
Host: your-domain.com
Content-Type: application/json
X-JobsDone-Signature: sha256=abc123def456...  # HMAC-SHA256 signature
X-JobsDone-Logic-ID: daily-report-acme-corp
X-JobsDone-Cron-ID: 42

{
  "tenant_id": "acme-corp",
  "report_type": "daily-summary",
  "format": "json"
}
```

### Verifying Signatures

Each cron job has a unique `webhook_secret` (auto-generated if not provided). For enqueued jobs, include `webhook_secret` in the description when enqueueing.

The `X-JobsDone-Signature` header contains an HMAC-SHA256 signature of the request body using your secret:

```
X-JobsDone-Signature: sha256=<hex_digest>
```

**Algorithm:**
1. Take the raw JSON request body (exactly as sent, without transformations)
2. Compute HMAC-SHA256 using your `webhook_secret` as the key
3. Compare with the hex digest in the header (after `sha256=`)

#### Node.js Example

```typescript
import { createHmac } from 'crypto';

function verifyWebhookSignature(secret: string, body: string, signature: string): boolean {
  const expected = createHmac('sha256', secret)
    .update(body, 'utf8')
    .digest('hex');
  
  // Use timing-safe comparison to prevent timing attacks
  if (signature.length !== expected.length) return false;
  
  let result = 0;
  for (let i = 0; i < signature.length; i++) {
    result |= signature.charCodeAt(i) ^ expected.charCodeAt(i);
  }
  return result === 0;
}

// In your Express handler:
app.post('/webhooks/daily-report', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-jobsdone-signature']?.replace('sha256=', '');
  const isValid = verifyWebhookSignature(WEBHOOK_SECRET, req.body.toString(), signature);
  
  if (!isValid) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  
  // Process the webhook...
  const payload = JSON.parse(req.body.toString());
  // ...
  
  res.status(200).json({ ok: true });
});
```

#### Python Example

```python
import hmac
import hashlib

def verify_webhook_signature(secret: str, body: bytes, signature: str) -> bool:
    expected = hmac.new(
        secret.encode('utf-8'),
        body,
        hashlib.sha256
    ).hexdigest()
    
    return hmac.compare_digest(signature, expected)

# In your Flask handler:
@app.route('/webhooks/daily-report', methods=['POST'])
def webhook():
    signature = request.headers.get('X-JobsDone-Signature', '').replace('sha256=', '')
    body = request.get_data()
    
    if not verify_webhook_signature(WEBHOOK_SECRET, body, signature):
        return jsonify({'error': 'Invalid signature'}), 401
    
    payload = request.get_json()
    # Process the webhook...
    return jsonify({'ok': True})
```

#### Go Example

```go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"net/http"
)

func verifyWebhookSignature(secret, body, signature string) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(body))
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(signature), []byte(expected))
}

// In your HTTP handler:
func webhookHandler(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)
	sig := strings.TrimPrefix(r.Header.Get("X-JobsDone-Signature"), "sha256=")
	
	if !verifyWebhookSignature(WEBHOOK_SECRET, string(body), sig) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}
	
	// Process the webhook...
}
```

### Response & Retries

```typescript
// Return 2xx to acknowledge. Jobs Done retries on non-2xx responses.
// The number of retries is controlled by max_attempts (default: 3).

app.post('/webhooks/daily-report', async (req, res) => {
  const { tenant_id, report_type } = req.body;

  // Process the report...
  await generateReport({ tenantId: tenant_id, type: report_type });

  res.status(200).json({ ok: true });
});
```

### WARNING: Webhook Security

**Always verify the `X-JobsDone-Signature` header** to cryptographically confirm that webhooks originate from Jobs Done. This prevents spoofed job executions.

Configure your webhook endpoint to **only accept requests from the Jobs Done domain** (`api.jobsdone.dev`). This provides an additional layer of protection.

---

## OpenAPI Specification

[BOOK] The full OpenAPI 3.0 specification is available at `GET /api/openapi`. Use the interactive SwaggerUI to explore all endpoints, request/response schemas, and try out API calls directly.

### OpenAPI JSON

`GET /api/openapi`

Returns the complete OpenAPI 3.0 specification as JSON. Use this to generate SDKs or integrate with tools like Postman.

[View OpenAPI JSON](/api/openapi)

### SwaggerUI

`GET /swaggerui`

Interactive API documentation. Explore all endpoints, see request/response schemas, and test API calls directly from the browser.

[Open SwaggerUI](/swaggerui)

---

## Use Cases

### [EMAIL] Retry-safe welcome emails

Enqueue with a stable `logic_id` like `welcome-user-42`. Call it on signup *and* on every deploy -- you'll never send duplicates.

```bash
$ jobs_done jobs enqueue \
  --id "welcome-user-42" \
  --url ".../webhooks/welcome"
```

### [STATS] Daily report emails

Fire a webhook every morning at 09:00 UTC. Your handler queries the DB, renders the report, and sends it -- Jobs Done handles the clock.

```bash
$ jobs_done cron create \
  --name "daily-report" \
  --cron "0 9 * * *" \
  --url ".../webhooks/report"
```

### [STORAGE] Scheduled data exports

Trigger a nightly data export to S3 or a data warehouse. If the export fails, automatic retries ensure it completes before morning.

```bash
$ jobs_done cron create \
  --name "nightly-export" \
  --cron "0 2 * * *" \
  --url ".../webhooks/export"
```

### [RETRY] Order confirmation workflows

When an order is placed, enqueue a confirmation job with a unique `logic_id`. If your payment processor webhooks back with success, enqueue the fulfillment job with its own `logic_id`. Each step retries independently on failure.

```bash
$ jobs_done jobs enqueue \
  --id "order-confirm-12345" \
  --url ".../webhooks/confirm"

$ jobs_done jobs enqueue \
  --id "order-fulfill-12345" \
  --url ".../webhooks/fulfill"
```

### [CLOCK] Weekly digest emails

Send a weekly summary every Monday at 08:00 UTC. Jobs Done handles the scheduling -- your handler just receives the webhook and queries the data it needs.

```bash
$ jobs_done cron create \
  --name "weekly-digest" \
  --cron "0 8 * * 1" \
  --url ".../webhooks/digest"
```

---

## Footer

(c) 2026 Jobs Done.

- [Home](/)
- [API Reference](/swaggerui)
- [Contact](mailto:b@claudlabs.com)
