API reference contents
API reference
Documentation
Code examples
Four clients, the same four jobs: authenticate, list work, find something needing approval, and complete it with a note. Working code, in full.
Each program below does exactly the same thing in the same order, so they can be read
against each other. Each one reads the key from OUTCOMEDEV_API_KEY, lists the
work, picks the first row whose action is approve, reads it to show the
message that is about to go out, and completes it with a note and an idempotency key.
Every one of these sends a real message. The final step on a row whose
action is approve releases the prepared message to a real counterparty,
through the owner's own mailbox. Run these against a desk where that is acceptable, or
stop before the last call while reading them.
Before any of them
export OUTCOMEDEV_API_KEY=od_K7fQ2mVx8sT1pR4nB6wY3zA
The key comes from the account menu inside the product, as described in Authentication. It is read from the environment in every example, because a key in a source file is a key in a repository.
curl
No build step, and the fastest way to check the shape of anything. The four calls are separate here because that is how they are useful interactively.
#!/usr/bin/env bash
set -euo pipefail
API="https://outcomedev.com/api/v1"
AUTH="Authorization: Bearer ${OUTCOMEDEV_API_KEY:?set OUTCOMEDEV_API_KEY first}"
JSON="Content-Type: application/json"
# 1. Authenticate. The root confirms the key and names the desk.
curl -sS "$API/" -H "$AUTH"
echo
# 2. List work. Only what is still open.
curl -sS "$API/work?status=open&limit=50" -H "$AUTH" \
| tee /tmp/od-work.json
echo
# 3. Find something needing approval.
ID=$(jq -r '.data.items[] | select(.action.type == "approve") | .id' /tmp/od-work.json | head -1)
if [ -z "$ID" ]; then
echo "Nothing is waiting for approval."
exit 0
fi
echo "Work item $ID needs approval."
# Read it first, so the message going out is the message that was meant.
curl -sS "$API/work/$ID" -H "$AUTH" \
| jq '.data.messages[] | select(.status == "drafted") | { subject, body, to: .actor }'
# 4. Complete it, with a note and an idempotency key.
curl -sS -X POST "$API/work/$ID/complete" \
-H "$AUTH" -H "$JSON" \
-H "Idempotency-Key: complete-$ID-$(date -u +%Y-%m-%d)" \
-d '{"note":"Reviewed and approved.","actor":"examples-curl"}' \
| jq .
Output, on a desk with three open rows one of which is waiting for approval:
{
"api": "OutcomeDev",
"version": "v1",
"desk": "northgate",
"key": { "id": 4, "name": "examples-curl", "prefix": "od_K7fQ2mVx" },
"documentation": "https://outcomedev.com/docs/api/",
"openapi": "/api/v1/openapi.json",
"note": "Each key acts as one desk. Writes here are the same actions a person takes on the screen, and anything that sends a message goes out on the owner's behalf after they approved it."
}
...
Work item 47 needs approval.
{
"subject": "Business licence renewal - Northgate",
"body": "Hello,\n\nThe business licence for Northgate is due for renewal on 28 February 2027. Could you confirm the renewal fee and the documents needed?\n\nThank you.",
"to": "desk"
}
{
"data": {
"id": 47,
"status": "waiting",
"waiting_on": "the council",
"failed": false,
"delivered": true,
"delivery_note": "Sent from practice@northgate.example."
}
}
The response to the first call is printed without the data wrapper because
jq . was not applied to it — the raw bytes include it. A client should
always read .data.
JavaScript
Node 18 or later, no dependencies. Uses the built-in fetch.
// node --env-file=.env examples.mjs
const BASE = 'https://outcomedev.com/api/v1'
const key = process.env.OUTCOMEDEV_API_KEY
if (!key) throw new Error('Set OUTCOMEDEV_API_KEY first.')
const headers = { Authorization: `Bearer ${key}` }
/** Every response is wrapped: read data on success, error on failure. */
async function call(path, init = {}) {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
...headers,
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...init.headers,
},
})
const body = await res.json().catch(() => null)
if (!res.ok) {
const err = body && body.error ? body.error : { code: 'unknown', message: `HTTP ${res.status}` }
const e = new Error(err.message)
e.code = err.code
e.status = res.status
e.details = err.details
throw e
}
return body.data
}
// 1. Authenticate.
const root = await call('/')
console.log(`Connected to the ${root.desk} desk as "${root.key.name}".`)
// 2. List work.
const page = await call('/work?status=open&limit=50')
console.log(`${page.open_total} open, ${page.items.length} on this page.`)
// 3. Find something needing approval.
const row = page.items.find((item) => item.action && item.action.type === 'approve')
if (!row) {
console.log('Nothing is waiting for approval.')
} else {
console.log(`#${row.id} ${row.title} - ${row.action.label}`)
// Read it first: completing sends the prepared message.
const full = await call(`/work/${row.id}`)
const draft = full.messages.find((m) => m.status === 'drafted')
if (draft) {
console.log('--- about to go out ---')
console.log(`Subject: ${draft.subject}`)
console.log(draft.body)
console.log('-----------------------')
}
// 4. Complete it. The same key on a retry returns this response, not a second send.
const done = await call(`/work/${row.id}/complete`, {
method: 'POST',
headers: { 'Idempotency-Key': `complete-${row.id}-${new Date().toISOString().slice(0, 10)}` },
body: JSON.stringify({ note: 'Reviewed and approved.', actor: 'examples-node' }),
})
console.log(`#${done.id} is now ${done.status}.`)
console.log(done.delivery_note)
if (done.failed) {
// The desk meant to send and could not. The row is open again with the reason attached.
process.exitCode = 1
}
}
Note the two behaviours a client has to get right, and both are in this file. A non-2xx
response is turned into an error carrying code, so a caller can branch on
not_permitted rather than on a status. And failed is checked
after a successful response, because a 200 can still mean the message did not go.
Python
Python 3.8 or later, with requests.
"""python examples.py"""
import os
import datetime
import requests
BASE = "https://outcomedev.com/api/v1"
KEY = os.environ.get("OUTCOMEDEV_API_KEY")
if not KEY:
raise SystemExit("Set OUTCOMEDEV_API_KEY first.")
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {KEY}"
def call(path, method="GET", **kwargs):
"""Read data on success, raise on failure with the API's own code."""
res = SESSION.request(method, BASE + path, timeout=60, **kwargs)
try:
body = res.json()
except ValueError:
body = None
if not res.ok:
err = (body or {}).get("error", {})
raise RuntimeError(f"{err.get('code', 'unknown')}: {err.get('message', res.status_code)}")
return body["data"]
# 1. Authenticate.
root = call("/")
print(f"Connected to the {root['desk']} desk as \"{root['key']['name']}\".")
# 2. List work.
page = call("/work", params={"status": "open", "limit": 50})
print(f"{page['open_total']} open, {len(page['items'])} on this page.")
# 3. Find something needing approval.
row = next(
(i for i in page["items"] if i.get("action") and i["action"]["type"] == "approve"),
None,
)
if row is None:
print("Nothing is waiting for approval.")
else:
print(f"#{row['id']} {row['title']} - {row['action']['label']}")
# Read it first: completing sends the prepared message.
full = call(f"/work/{row['id']}")
draft = next((m for m in full["messages"] if m["status"] == "drafted"), None)
if draft:
print("--- about to go out ---")
print(f"Subject: {draft['subject']}")
print(draft["body"])
print("-----------------------")
# 4. Complete it. The same key on a retry returns this response, not a second send.
today = datetime.date.today().isoformat()
done = call(
f"/work/{row['id']}/complete",
method="POST",
headers={"Idempotency-Key": f"complete-{row['id']}-{today}"},
json={"note": "Reviewed and approved.", "actor": "examples-python"},
)
print(f"#{done['id']} is now {done['status']}.")
print(done["delivery_note"])
if done["failed"]:
# The desk meant to send and could not. The row is open again with the reason attached.
raise SystemExit(1)
The timeout on the session is 60 seconds, which is right for everything here
except /chat and /run. Those two need minutes; pass a longer
timeout for them specifically rather than raising it for every call.
Go
Standard library only. Two structs describe what this program reads.
// go run examples.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const base = "https://outcomedev.com/api/v1"
var httpClient = &http.Client{Timeout: 60 * time.Second}
type Action struct {
Type string `json:"type"`
Label string `json:"label"`
Hint string `json:"hint"`
}
type WorkItem struct {
ID int `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Action *Action `json:"action"`
Permission *string `json:"permission"`
Allowed bool `json:"allowed"`
HasDraft bool `json:"has_draft"`
}
type WorkPage struct {
Items []WorkItem `json:"items"`
NextCursor *string `json:"next_cursor"`
OpenTotal int `json:"open_total"`
}
// call performs one request and unwraps the envelope. The API always answers
// {"data": ...} or {"error": {"code","message"}}, so this returns the raw
// payload and leaves the caller to unmarshal it.
func call(method, path, key string, body any, hdr map[string]string) (json.RawMessage, error) {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, base+path, reader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range hdr {
req.Header.Set(k, v)
}
res, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
// Every response is one of two envelopes.
var envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("unreadable response (%d): %s", res.StatusCode, raw)
}
if envelope.Error != nil {
return nil, fmt.Errorf("%s: %s", envelope.Error.Code, envelope.Error.Message)
}
return envelope.Data, nil
}
func main() {
key := os.Getenv("OUTCOMEDEV_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "Set OUTCOMEDEV_API_KEY first.")
os.Exit(1)
}
// 1. Authenticate.
raw, err := call("GET", "/", key, nil, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var root struct {
Desk string `json:"desk"`
Key struct {
Name string `json:"name"`
} `json:"key"`
}
if err := json.Unmarshal(raw, &root); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("Connected to the %s desk as %q.\n", root.Desk, root.Key.Name)
// 2. List work.
raw, err = call("GET", "/work?status=open&limit=50", key, nil, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var page WorkPage
if err := json.Unmarshal(raw, &page); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("%d open, %d on this page.\n", page.OpenTotal, len(page.Items))
// 3. Find something needing approval.
var row *WorkItem
for i := range page.Items {
if page.Items[i].Action != nil && page.Items[i].Action.Type == "approve" {
row = &page.Items[i]
break
}
}
if row == nil {
fmt.Println("Nothing is waiting for approval.")
return
}
fmt.Printf("#%d %s - %s\n", row.ID, row.Title, row.Action.Label)
// Read it first: completing sends the prepared message.
raw, err = call("GET", fmt.Sprintf("/work/%d", row.ID), key, nil, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var full struct {
Messages []struct {
Status string `json:"status"`
Subject *string `json:"subject"`
Body *string `json:"body"`
} `json:"messages"`
}
if err := json.Unmarshal(raw, &full); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, m := range full.Messages {
if m.Status == "drafted" {
fmt.Println("--- about to go out ---")
if m.Subject != nil {
fmt.Printf("Subject: %s\n", *m.Subject)
}
if m.Body != nil {
fmt.Println(*m.Body)
}
fmt.Println("-----------------------")
break
}
}
// 4. Complete it. The same key on a retry returns this response, not a second send.
raw, err = call(
"POST",
fmt.Sprintf("/work/%d/complete", row.ID),
key,
map[string]any{"note": "Reviewed and approved.", "actor": "examples-go"},
map[string]string{
"Idempotency-Key": fmt.Sprintf("complete-%d-%s", row.ID, time.Now().UTC().Format("2006-01-02")),
},
)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var done struct {
ID int `json:"id"`
Status string `json:"status"`
Failed bool `json:"failed"`
Delivered bool `json:"delivered"`
DeliveryNote string `json:"delivery_note"`
}
if err := json.Unmarshal(raw, &done); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("#%d is now %s.\n%s\n", done.ID, done.Status, done.DeliveryNote)
// A 200 can still mean the message did not go.
if done.Failed {
os.Exit(1)
}
}
What all four do the same way
| Decision | Why |
|---|---|
| Read the key from the environment | A key in a source file is a key in a repository |
Treat data and error as the only two shapes | Every response in version 1 is one of them, with no exceptions |
Branch on error.code, not on the status | Two different 403s mean two very different things |
| Read the row before completing it | Completing sends the prepared message. It should be read first |
Send an Idempotency-Key derived from the row and the action | A network retry must not be a second message to a counterparty |
Check failed after a successful response | A 200 can carry news that nothing was sent |
Accept waiting as a successful completion | The message went out and an answer is owed. That is what success looks like here |
Related
- Quick start — the same walkthrough with commentary on each response.
- Errors — what each code means, and which one is worth retrying.
- MCP server — if a client does not need to be written at all.