User Guide
Everything you need to wire jobs into Did It Work and get alerted when they go quiet. Machine-readable version: docs.md.
Core concepts
- Organization (org) — your account. All jobs, keys, and users belong to an org.
- Job (monitor) — one thing you expect to run: a cron job, a pipeline, an agent task.
- Run — one execution of a job, with status, timing, and a timeline of events.
- Ingest key — a write-only machine token your jobs use to send pings, passed as an
Authorization: Bearerheader. Never usable to read data or log in. - Label — a name defined by your org. Labels connect ingest keys to jobs: a key can only ping jobs it shares at least one label with.
- Contact point — where alerts go: an email address or a webhook.
- Alert rule — a condition on a job's events that opens an incident and notifies a contact point.
- Incident — a firing alert. Open until resolved, automatically or manually.
Signing in is passwordless: enter your email and click the magic link we send you.
Job types
Simple — one ping, “it ran”
Use when you only need to know that the job ran. One HTTP call at the end of
the job is the entire integration — right for most cron jobs: backups, certificate
renewals, cleanup scripts. The optional JSON body
({"status", "message", "labels"}) is just extra context; an empty POST is a
valid heartbeat.
# at the end of your job — or chained in the crontab entry itself:
curl -fsS -X POST "https://diw.run/ping/YOUR_JOB_ID" \
-H "Authorization: Bearer $DIW_INGEST_KEY"
# crontab example — ping only if the job succeeded
0 3 * * * /usr/local/bin/backup.sh && curl -fsS -X POST "https://diw.run/ping/YOUR_JOB_ID" -H "Authorization: Bearer $DIW_INGEST_KEY"
# at the end of your scheduled task
Invoke-RestMethod -Method Post `
-Uri "https://diw.run/ping/YOUR_JOB_ID" `
-Headers @{ Authorization = "Bearer $env:DIW_INGEST_KEY" }
import os, requests
requests.post(
"https://diw.run/ping/YOUR_JOB_ID",
headers={"Authorization": f"Bearer {os.environ['DIW_INGEST_KEY']}"},
timeout=10,
).raise_for_status()
req, _ := http.NewRequest("POST", "https://diw.run/ping/YOUR_JOB_ID", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("DIW_INGEST_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
resp.Body.Close()
Advanced — full run lifecycle
Use when you care about how the job ran: duration, success vs failure, exit codes, and progress messages. Advanced jobs report an explicit start and end, so you also catch a job that starts and then hangs — something a single end-of-job ping can never tell you.
POST /ping/{job-id}/start → {"status":"accepted","run_id":"..."}
POST /ping/{job-id}/{run-id}/message {"message":"...", "labels":{...}}
POST /ping/{job-id}/{run-id}/end {"status":"success|failure", "exit_code":N, ...}
/start returns a run_id; pass it to every later call. Messages are
optional, appear on the run timeline, and can drive message-based alerting.
BASE="https://diw.run/ping/YOUR_JOB_ID"
AUTH="Authorization: Bearer $DIW_INGEST_KEY"
RUN_ID=$(curl -fsS -X POST "$BASE/start" -H "$AUTH" | jq -r .run_id)
curl -fsS -X POST "$BASE/$RUN_ID/message" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"message":"extract finished, 1200 rows","labels":{"stage":"extract"}}'
./run-the-job
EXIT=$?
STATUS=$([ "$EXIT" -eq 0 ] && echo success || echo failure)
curl -fsS -X POST "$BASE/$RUN_ID/end" -H "$AUTH" \
-H "Content-Type: application/json" \
-d "{\"status\":\"$STATUS\",\"exit_code\":$EXIT}"
$base = "https://diw.run/ping/YOUR_JOB_ID"
$headers = @{ Authorization = "Bearer $env:DIW_INGEST_KEY" }
$run = Invoke-RestMethod -Method Post -Uri "$base/start" -Headers $headers
& .\run-the-job.ps1
$status = if ($LASTEXITCODE -eq 0) { "success" } else { "failure" }
Invoke-RestMethod -Method Post -Uri "$base/$($run.run_id)/end" -Headers $headers `
-ContentType "application/json" `
-Body (@{ status = $status; exit_code = $LASTEXITCODE } | ConvertTo-Json)
import os, subprocess, requests
BASE = "https://diw.run/ping/YOUR_JOB_ID"
HEADERS = {"Authorization": f"Bearer {os.environ['DIW_INGEST_KEY']}"}
run_id = requests.post(f"{BASE}/start", headers=HEADERS, timeout=10).json()["run_id"]
result = subprocess.run(["./run-the-job"])
status = "success" if result.returncode == 0 else "failure"
requests.post(
f"{BASE}/{run_id}/end",
headers=HEADERS,
json={"status": status, "exit_code": result.returncode},
timeout=10,
)
base := "https://diw.run/ping/YOUR_JOB_ID"
key := os.Getenv("DIW_INGEST_KEY")
post := func(path string, payload any) map[string]any {
var buf bytes.Buffer
if payload != nil {
json.NewEncoder(&buf).Encode(payload)
}
req, _ := http.NewRequest("POST", base+path, &buf)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
return out
}
runID := post("/start", nil)["run_id"].(string)
err := runTheJob()
status, code := "success", 0
if err != nil {
status, code = "failure", 1
}
post("/"+runID+"/end", map[string]any{"status": status, "exit_code": code})
Choosing a type
| You want to know | Use |
|---|---|
| “Did my cron job run today?” | Simple |
| “Did it run, how long did it take, did it fail, and why?” | Advanced |
Any event can carry labels. They show up in the UI and can be matched by alert
conditions (e.g. alert only when labels.severity equals critical).
Triggers and alerts
Alerting is configured per job in the Alerts panel. A rule reads like a sentence: When this happens · And only if · Wait for · Then do this · Resolve when.
| Trigger event | Fires when | Job types |
|---|---|---|
job.heartbeat | a simple ping arrives | simple |
job.heartbeat.missed | a heartbeat did not arrive within the expected window | simple |
job.started | a run starts | advanced |
job.message | a message event arrives | advanced |
job.completed | a run ends (status: success | failure) | advanced |
job.timed_out | a run started but did not end within timeout + grace | advanced |
Conditions narrow the trigger (match all or any): completion status, exit
code, message contains "…", and labels.<key> eq <value>.
Common recipes:
- Job failed — trigger
job.completed, conditionstatus eq failure. - Cron went silent — trigger
job.heartbeat.missed. The check window is the expected interval plus allowed runtime plus grace; the UI shows the full window. - Run hangs — trigger
job.timed_out, or triggerjob.startedand wait forjob.completedwithin a window. - Log content — trigger
job.messagewithmessage contains "ERROR", or matchlabels.severity eq critical. - Nothing ran this hour/day/week/month — an activity-window rule fires at the end of a calendar window with no activity.
Contact points (email or webhook with your URL/method/headers) are managed
org-wide, with a test button for sample deliveries. A firing rule opens an
incident; repeat notifications can re-notify at an interval with an optional
maximum. Incidents resolve automatically on the rule's resolve event or manually from
the UI. Every job shows an alert badge, and the org view lists all firing incidents.
Time fields accept durations like 30s, 5m, 4h, 7d, 1M.
Users and roles
Under Settings → Users (owners only): invite by email — the invitee gets a magic link, no passwords — change roles, or remove users at any time.
| Role | Can do |
|---|---|
owner | Everything: users, org settings, billing, plus all of the below |
job_maintainer | Create/edit/delete jobs, labels, ingest keys, and alert rules |
member | Read-only: view jobs, runs, logs, and alerting |
All configuration changes (and any support access by our staff) are recorded in a read-only audit log for owners under Settings → Audit log, searchable by action and time.
Ingest keys and permissions
Under Settings → Ingestion (owners and job maintainers):
- Create — name the key and select one or more labels. The secret is shown once; store it in your secret manager. We keep only a hash.
- Use —
Authorization: Bearer <key>from your jobs. Keys are write-only: they can send pings and nothing else. - Revoke — delete the key; its pings stop being accepted immediately.
- Rotate — replace issues a new secret with the same label setup, so you can swap secrets without rebuilding permissions.
Labels are the permission system: a key can only ping a job it shares at least one
label with (a key with no labels can ping nothing). Typical setup: one label per team
or environment (payments, staging), attached to the relevant jobs, with
each team/host issued a key carrying only its label — a leaked key exposes one slice
of your jobs, and revocation is one click.
Organization settings
- Rename your org — Settings → General (owners). The name appears in the UI and in alert notifications.
- Audit log — see Users and roles above.
Billing and payments
Under Settings → Billing (owners only). Payments are processed by Paddle, our Merchant of Record — card details never touch our servers, and Paddle handles invoicing and sales tax/VAT.
- Plans are additive packages. Every org starts on Free. Buying a package adds its job limits to your total; stack packages and mix tiers freely.
- First purchase opens a Paddle checkout overlay; later purchases charge the subscription with immediate proration.
- Update payment card — the update payment method action opens a Paddle overlay to change the saved card.
- Invoices and history — the transactions list shows all charges with downloadable invoices, plus any credit balance.
- Removing a package takes effect immediately; the unused remainder of the billing period returns as a Paddle credit applied to future invoices (not a card refund). You can't remove a package your current job count needs — archive or delete jobs first. Removing the last package returns the org to Free.