deliverd 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ # dependencies
2
+ node_modules/
3
+ .pnpm-store/
4
+
5
+ # next.js
6
+ apps/web/.next/
7
+ apps/web/out/
8
+ apps/web/next-env.d.ts
9
+
10
+ # builds
11
+ dist/
12
+ *.tsbuildinfo
13
+
14
+ # env
15
+ .env
16
+ .env.local
17
+ .env*.local
18
+
19
+ # supabase
20
+ supabase/.branches
21
+ supabase/.temp
22
+
23
+ # testing
24
+ coverage/
25
+
26
+ # misc
27
+ .DS_Store
28
+ *.pem
29
+ .vercel
30
+
31
+ # Python (packages/sdk-python)
32
+ __pycache__/
33
+ *.py[cod]
34
+ .pytest_cache/
35
+ *.egg-info/
deliverd-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Deliverd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,342 @@
1
+ Metadata-Version: 2.5
2
+ Name: deliverd
3
+ Version: 0.1.0
4
+ Summary: Add human approval to any AI agent. Ask a person, wait for the answer, act on it.
5
+ Project-URL: Homepage, https://deliverd.dev/developers
6
+ Project-URL: Documentation, https://deliverd.dev/developers
7
+ Project-URL: Source, https://github.com/davidpreid/deliverd
8
+ Project-URL: Issues, https://github.com/davidpreid/deliverd/issues
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Deliverd
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: agent,ai,approval,deliverd,human-in-the-loop,mcp
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.9
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Programming Language :: Python :: 3.13
41
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
+ Classifier: Typing :: Typed
43
+ Requires-Python: >=3.9
44
+ Description-Content-Type: text/markdown
45
+
46
+ # deliverd
47
+
48
+ **Add human approval to any AI agent.** Ask a person, wait for the answer, act
49
+ on it.
50
+
51
+ ```bash
52
+ pip install deliverd
53
+ ```
54
+
55
+ ```python
56
+ from deliverd import deliverd
57
+
58
+ decision = deliverd.approve(title="Refund £1,240 to Acme", approvers=["Finance"])
59
+ if decision.approved:
60
+ refund()
61
+ ```
62
+
63
+ That is the whole integration. The call blocks until somebody decides, and
64
+ returns whether they said yes.
65
+
66
+ What it stands in for: an approval page, the emails and the reminders, identity
67
+ and single sign-on, who is allowed to decide, a table to keep it in, an audit
68
+ trail, and the dashboard somebody uses to do it. None of that is yours to
69
+ build.
70
+
71
+ - **No dependencies.** The standard library has HTTP, HMAC and JSON. Nothing
72
+ new lands in your agent's environment.
73
+ - **Python 3.9 and up.**
74
+ - **Calls block.** The function that asks is the function that answers, which
75
+ is the shape an agent tool wants. There is no async client yet — see
76
+ [Where this is going](#where-this-is-going).
77
+
78
+ ---
79
+
80
+ ## Before you have an account
81
+
82
+ ```python
83
+ from deliverd import Deliverd
84
+
85
+ deliverd = Deliverd(mode="development")
86
+ decision = deliverd.approve(title="Deploy to production")
87
+ print(decision.approved) # True
88
+ ```
89
+
90
+ No key, no network, no colleague to interrupt. The imaginary approver decides
91
+ instantly, and the four things that can happen to a real request are all one
92
+ line away:
93
+
94
+ ```python
95
+ Deliverd(mode="development", development=DevelopmentOptions(outcome="rejected"))
96
+ Deliverd(mode="development", development=DevelopmentOptions(outcome="timeout"))
97
+ Deliverd(mode="development", development=DevelopmentOptions(outcome="question"))
98
+ ```
99
+
100
+ or `DELIVERD_DEV_OUTCOME=question` in the environment, so a whole test suite
101
+ flips without touching the code.
102
+
103
+ **`question` is the one people forget to write.** An approver can ask the agent
104
+ something before deciding, and until it is answered nothing moves:
105
+
106
+ ```python
107
+ decision = deliverd.approve(
108
+ title="Refund £1,240 to Acme",
109
+ on_question=lambda q, approval: "Yes — staged on Tuesday",
110
+ )
111
+ ```
112
+
113
+ Return `None` from the handler to leave it for a person.
114
+
115
+ Development mode is a stand-in transport, not a branch inside the client, so
116
+ the retries, the error handling and the polling are the same code that runs in
117
+ production. It answers the calls the primitives make and returns 501 for
118
+ everything else, rather than pretending to be the whole API.
119
+
120
+ ## Configuration
121
+
122
+ ```python
123
+ from deliverd import Deliverd
124
+
125
+ deliverd = Deliverd() # DELIVERD_API_KEY from the environment
126
+ deliverd = Deliverd(api_key="dlv_…") # or pass it
127
+ ```
128
+
129
+ | Variable | What |
130
+ |---|---|
131
+ | `DELIVERD_API_KEY` | A `dlv_` key from Settings → API tokens, or a `dlvo_` OAuth access token |
132
+ | `DELIVERD_BASE_URL` | Override for a self-hosted deployment |
133
+ | `DELIVERD_MODE` | `development` to run the loop offline |
134
+ | `DELIVERD_DEV_OUTCOME` | `approved` · `rejected` · `timeout` · `question` |
135
+
136
+ The module-level `deliverd` builds itself from the environment the first time
137
+ you touch it, so the shortest form has no setup line at all. Build a `Deliverd`
138
+ yourself when you need two keys, a different base URL, or development mode.
139
+
140
+ ## The four verbs
141
+
142
+ | | You want | Call |
143
+ |---|---|---|
144
+ | **Approval** | May I do this? | `approve()` |
145
+ | **Confirmation** | Are you sure? — lower ceremony | `confirm()` |
146
+ | **Review** | Is what I made right? | `review()` |
147
+ | **Information** | A figure, a date, a choice | `collect()` |
148
+ | **Publishing** | People need to read this | `publish()` |
149
+
150
+ ### Approve
151
+
152
+ ```python
153
+ decision = deliverd.approve(
154
+ title="Refund £1,240 to Acme",
155
+ description="Duplicate charge on invoice 4821.",
156
+ risk="high",
157
+ factors=[{"label": "Customer charged twice", "status": "warning"}],
158
+ links=[{"label": "Invoice 4821", "url": "https://…"}],
159
+ approvers=["Finance"], # a person, a team, a group — or omit it
160
+ expires_in="4h",
161
+ )
162
+
163
+ decision.approved # True only for an approval
164
+ decision.status # approved | rejected | expired | cancelled
165
+ decision.note # their reason, when they gave one
166
+ decision.decided_by # "Sarah Chen" — a name, not an id
167
+ decision.decided_by_id # their user id, when the key is what you need
168
+ decision.url # the page they decided on
169
+ ```
170
+
171
+ **A refusal is an answer, not an error.** `approve()` returns on rejected,
172
+ expired and cancelled as well as approved; it raises only when the *wait*
173
+ failed — a timeout, a cancellation, or an API it could not reach.
174
+
175
+ `approvers` takes a person (email or user id), the name of a team or directory
176
+ group, or a word meaning the whole organisation. Naming a team is usually what
177
+ you want: a request addressed to one person waits for that person to come back
178
+ from holiday.
179
+
180
+ ### Review
181
+
182
+ ```python
183
+ outcome = deliverd.review(
184
+ title="Q3 board pack",
185
+ report_id=report.id,
186
+ instructions="Check the figures against the ledger.",
187
+ reviewers=["partner@firm.example"],
188
+ )
189
+ if not outcome.approved:
190
+ revise(outcome.verdicts, outcome.thread_count)
191
+ ```
192
+
193
+ ### Collect
194
+
195
+ ```python
196
+ answers = deliverd.collect(
197
+ title="Before I file the Q3 return",
198
+ respondents=["finance@firm.example"],
199
+ questions=[
200
+ {"prompt": "Headcount at 30 September", "kind": "number"},
201
+ {"prompt": "Any disposals in the quarter?", "kind": "boolean"},
202
+ ],
203
+ )
204
+ if answers.complete:
205
+ file_return(answers["Headcount at 30 September"])
206
+ ```
207
+
208
+ `answers` is keyed by the question as you asked it. Check `complete` before you
209
+ use it: a request that expired has whatever arrived and no more.
210
+
211
+ ### Publish
212
+
213
+ ```python
214
+ result = deliverd.publish(title="Weekly figures", content=html, audience=["Acme"])
215
+ if result.held:
216
+ print("Waiting on a person before anyone can open it")
217
+ else:
218
+ print(result.url)
219
+ ```
220
+
221
+ ## Restarting safely
222
+
223
+ Give a request your own `external_id` and a restarted agent finds the request it
224
+ already filed rather than asking two people about one act:
225
+
226
+ ```python
227
+ deliverd.approve(title="Refund £1,240", external_id=f"refund-{invoice.id}")
228
+ ```
229
+
230
+ That is best effort — two *concurrent* calls can still both find nothing. For
231
+ the guarantee, pass `idempotency_key`, which the server enforces.
232
+
233
+ ## Waiting
234
+
235
+ ```python
236
+ approval = deliverd.approvals.create(title="Deploy production")
237
+ print("Waiting at", approval.url)
238
+ approval.wait(on_poll=lambda a: print(".", end=""), timeout="30m")
239
+ ```
240
+
241
+ Polling backs off — a quick first look so a fast decision reads as one, then
242
+ somebody's afternoon rather than a progress bar. Pass a `threading.Event` as
243
+ `cancel` to stop a wait from another thread.
244
+
245
+ Every request expires, so a wait always ends: 24 hours unless you pass
246
+ `expires_in`.
247
+
248
+ > **Durations are seconds here, not milliseconds.** `timeout=30` is half a
249
+ > minute, because `time.sleep` takes seconds and so does everything else in
250
+ > Python. The string forms — `"30m"`, `"4h"`, `"2d"` — and `timedelta` mean the
251
+ > same thing in every Deliverd SDK. (The TypeScript package reads a bare number
252
+ > as milliseconds, for the mirror-image reason.)
253
+
254
+ ## Errors
255
+
256
+ ```python
257
+ from deliverd import DeliverdError, DeliverdTimeoutError
258
+
259
+ try:
260
+ deliverd.approve(title="…")
261
+ except DeliverdTimeoutError:
262
+ ... # you stopped waiting; the request is still open
263
+ except DeliverdError as err:
264
+ err.code # the API's own code: self_approval, rate_limited…
265
+ err.is_auth # 401 or 403
266
+ err.is_rate_limit # 429
267
+ err.is_not_found # 404
268
+ err.is_conflict # 409 — re-read and try again
269
+ err.hint # the one thing to do next
270
+ ```
271
+
272
+ Messages say what happened and what to do about it, rather than handing you a
273
+ code to search for.
274
+
275
+ ## Webhooks
276
+
277
+ ```python
278
+ from deliverd import construct_event, SIGNATURE_HEADER
279
+
280
+ @app.post("/webhooks/deliverd")
281
+ def receive():
282
+ event = construct_event(
283
+ secret=os.environ["DELIVERD_WEBHOOK_SECRET"],
284
+ body=request.get_data(as_text=True), # the RAW body
285
+ header=request.headers.get(SIGNATURE_HEADER),
286
+ )
287
+ if event.event == "approval.approved":
288
+ ...
289
+ ```
290
+
291
+ `body` must be the raw request body. A re-encoded object is a different string
292
+ and will never verify — in FastAPI that is `(await request.body()).decode()`, in
293
+ Django `request.body.decode()`.
294
+
295
+ `construct_event` raises rather than returning `None`, because a route that
296
+ treats an unverifiable body as "no event" answers 200 and tells whoever is
297
+ posting that everything is fine.
298
+
299
+ ## Lists
300
+
301
+ ```python
302
+ deliverd.approvals.list() # every one, following the cursor
303
+ deliverd.approvals.list(limit=20) # one page of twenty
304
+ page = deliverd.approvals.list_page(limit=20)
305
+ page.items, page.next_cursor
306
+ ```
307
+
308
+ **A `limit` means one page. No `limit` means all of them.** A full page and a
309
+ complete list are otherwise indistinguishable until somebody counts.
310
+
311
+ ## Publishing (maintainers)
312
+
313
+ Tag and push; GitHub Actions builds, re-runs the suite on Python 3.9, and uploads
314
+ through PyPI Trusted Publishing — there is no API token anywhere.
315
+
316
+ ```bash
317
+ git tag python-v0.1.0 && git push origin python-v0.1.0
318
+ ```
319
+
320
+ ## Where this is going
321
+
322
+ Named rather than left to be discovered:
323
+
324
+ - **No async client.** Everything blocks. If you are inside `asyncio`, run a
325
+ call in a thread (`await asyncio.to_thread(deliverd.approve, title=…)`) until
326
+ there is one.
327
+ - **`flows`, `comments` and `schedules` are not here yet.** Every primitive
328
+ takes `flow_id`, so a Python agent can join a flow; creating and completing
329
+ one is TypeScript, REST or MCP for now.
330
+
331
+ Both gaps are checked in this repository's own test suite, so they cannot
332
+ quietly become three.
333
+
334
+ ## Also
335
+
336
+ - **REST** — `docs/api.md`, and an OpenAPI document at `/openapi.json`
337
+ - **MCP** — a remote server any agent can connect to, at `deliverd.dev/api/mcp`
338
+ - **TypeScript** — [`@deliverd/sdk`](https://www.npmjs.com/package/@deliverd/sdk),
339
+ the same shapes with `await`
340
+ - **CLI** — `npm i -g deliverd`, for a build step
341
+
342
+ MIT.
@@ -0,0 +1,297 @@
1
+ # deliverd
2
+
3
+ **Add human approval to any AI agent.** Ask a person, wait for the answer, act
4
+ on it.
5
+
6
+ ```bash
7
+ pip install deliverd
8
+ ```
9
+
10
+ ```python
11
+ from deliverd import deliverd
12
+
13
+ decision = deliverd.approve(title="Refund £1,240 to Acme", approvers=["Finance"])
14
+ if decision.approved:
15
+ refund()
16
+ ```
17
+
18
+ That is the whole integration. The call blocks until somebody decides, and
19
+ returns whether they said yes.
20
+
21
+ What it stands in for: an approval page, the emails and the reminders, identity
22
+ and single sign-on, who is allowed to decide, a table to keep it in, an audit
23
+ trail, and the dashboard somebody uses to do it. None of that is yours to
24
+ build.
25
+
26
+ - **No dependencies.** The standard library has HTTP, HMAC and JSON. Nothing
27
+ new lands in your agent's environment.
28
+ - **Python 3.9 and up.**
29
+ - **Calls block.** The function that asks is the function that answers, which
30
+ is the shape an agent tool wants. There is no async client yet — see
31
+ [Where this is going](#where-this-is-going).
32
+
33
+ ---
34
+
35
+ ## Before you have an account
36
+
37
+ ```python
38
+ from deliverd import Deliverd
39
+
40
+ deliverd = Deliverd(mode="development")
41
+ decision = deliverd.approve(title="Deploy to production")
42
+ print(decision.approved) # True
43
+ ```
44
+
45
+ No key, no network, no colleague to interrupt. The imaginary approver decides
46
+ instantly, and the four things that can happen to a real request are all one
47
+ line away:
48
+
49
+ ```python
50
+ Deliverd(mode="development", development=DevelopmentOptions(outcome="rejected"))
51
+ Deliverd(mode="development", development=DevelopmentOptions(outcome="timeout"))
52
+ Deliverd(mode="development", development=DevelopmentOptions(outcome="question"))
53
+ ```
54
+
55
+ or `DELIVERD_DEV_OUTCOME=question` in the environment, so a whole test suite
56
+ flips without touching the code.
57
+
58
+ **`question` is the one people forget to write.** An approver can ask the agent
59
+ something before deciding, and until it is answered nothing moves:
60
+
61
+ ```python
62
+ decision = deliverd.approve(
63
+ title="Refund £1,240 to Acme",
64
+ on_question=lambda q, approval: "Yes — staged on Tuesday",
65
+ )
66
+ ```
67
+
68
+ Return `None` from the handler to leave it for a person.
69
+
70
+ Development mode is a stand-in transport, not a branch inside the client, so
71
+ the retries, the error handling and the polling are the same code that runs in
72
+ production. It answers the calls the primitives make and returns 501 for
73
+ everything else, rather than pretending to be the whole API.
74
+
75
+ ## Configuration
76
+
77
+ ```python
78
+ from deliverd import Deliverd
79
+
80
+ deliverd = Deliverd() # DELIVERD_API_KEY from the environment
81
+ deliverd = Deliverd(api_key="dlv_…") # or pass it
82
+ ```
83
+
84
+ | Variable | What |
85
+ |---|---|
86
+ | `DELIVERD_API_KEY` | A `dlv_` key from Settings → API tokens, or a `dlvo_` OAuth access token |
87
+ | `DELIVERD_BASE_URL` | Override for a self-hosted deployment |
88
+ | `DELIVERD_MODE` | `development` to run the loop offline |
89
+ | `DELIVERD_DEV_OUTCOME` | `approved` · `rejected` · `timeout` · `question` |
90
+
91
+ The module-level `deliverd` builds itself from the environment the first time
92
+ you touch it, so the shortest form has no setup line at all. Build a `Deliverd`
93
+ yourself when you need two keys, a different base URL, or development mode.
94
+
95
+ ## The four verbs
96
+
97
+ | | You want | Call |
98
+ |---|---|---|
99
+ | **Approval** | May I do this? | `approve()` |
100
+ | **Confirmation** | Are you sure? — lower ceremony | `confirm()` |
101
+ | **Review** | Is what I made right? | `review()` |
102
+ | **Information** | A figure, a date, a choice | `collect()` |
103
+ | **Publishing** | People need to read this | `publish()` |
104
+
105
+ ### Approve
106
+
107
+ ```python
108
+ decision = deliverd.approve(
109
+ title="Refund £1,240 to Acme",
110
+ description="Duplicate charge on invoice 4821.",
111
+ risk="high",
112
+ factors=[{"label": "Customer charged twice", "status": "warning"}],
113
+ links=[{"label": "Invoice 4821", "url": "https://…"}],
114
+ approvers=["Finance"], # a person, a team, a group — or omit it
115
+ expires_in="4h",
116
+ )
117
+
118
+ decision.approved # True only for an approval
119
+ decision.status # approved | rejected | expired | cancelled
120
+ decision.note # their reason, when they gave one
121
+ decision.decided_by # "Sarah Chen" — a name, not an id
122
+ decision.decided_by_id # their user id, when the key is what you need
123
+ decision.url # the page they decided on
124
+ ```
125
+
126
+ **A refusal is an answer, not an error.** `approve()` returns on rejected,
127
+ expired and cancelled as well as approved; it raises only when the *wait*
128
+ failed — a timeout, a cancellation, or an API it could not reach.
129
+
130
+ `approvers` takes a person (email or user id), the name of a team or directory
131
+ group, or a word meaning the whole organisation. Naming a team is usually what
132
+ you want: a request addressed to one person waits for that person to come back
133
+ from holiday.
134
+
135
+ ### Review
136
+
137
+ ```python
138
+ outcome = deliverd.review(
139
+ title="Q3 board pack",
140
+ report_id=report.id,
141
+ instructions="Check the figures against the ledger.",
142
+ reviewers=["partner@firm.example"],
143
+ )
144
+ if not outcome.approved:
145
+ revise(outcome.verdicts, outcome.thread_count)
146
+ ```
147
+
148
+ ### Collect
149
+
150
+ ```python
151
+ answers = deliverd.collect(
152
+ title="Before I file the Q3 return",
153
+ respondents=["finance@firm.example"],
154
+ questions=[
155
+ {"prompt": "Headcount at 30 September", "kind": "number"},
156
+ {"prompt": "Any disposals in the quarter?", "kind": "boolean"},
157
+ ],
158
+ )
159
+ if answers.complete:
160
+ file_return(answers["Headcount at 30 September"])
161
+ ```
162
+
163
+ `answers` is keyed by the question as you asked it. Check `complete` before you
164
+ use it: a request that expired has whatever arrived and no more.
165
+
166
+ ### Publish
167
+
168
+ ```python
169
+ result = deliverd.publish(title="Weekly figures", content=html, audience=["Acme"])
170
+ if result.held:
171
+ print("Waiting on a person before anyone can open it")
172
+ else:
173
+ print(result.url)
174
+ ```
175
+
176
+ ## Restarting safely
177
+
178
+ Give a request your own `external_id` and a restarted agent finds the request it
179
+ already filed rather than asking two people about one act:
180
+
181
+ ```python
182
+ deliverd.approve(title="Refund £1,240", external_id=f"refund-{invoice.id}")
183
+ ```
184
+
185
+ That is best effort — two *concurrent* calls can still both find nothing. For
186
+ the guarantee, pass `idempotency_key`, which the server enforces.
187
+
188
+ ## Waiting
189
+
190
+ ```python
191
+ approval = deliverd.approvals.create(title="Deploy production")
192
+ print("Waiting at", approval.url)
193
+ approval.wait(on_poll=lambda a: print(".", end=""), timeout="30m")
194
+ ```
195
+
196
+ Polling backs off — a quick first look so a fast decision reads as one, then
197
+ somebody's afternoon rather than a progress bar. Pass a `threading.Event` as
198
+ `cancel` to stop a wait from another thread.
199
+
200
+ Every request expires, so a wait always ends: 24 hours unless you pass
201
+ `expires_in`.
202
+
203
+ > **Durations are seconds here, not milliseconds.** `timeout=30` is half a
204
+ > minute, because `time.sleep` takes seconds and so does everything else in
205
+ > Python. The string forms — `"30m"`, `"4h"`, `"2d"` — and `timedelta` mean the
206
+ > same thing in every Deliverd SDK. (The TypeScript package reads a bare number
207
+ > as milliseconds, for the mirror-image reason.)
208
+
209
+ ## Errors
210
+
211
+ ```python
212
+ from deliverd import DeliverdError, DeliverdTimeoutError
213
+
214
+ try:
215
+ deliverd.approve(title="…")
216
+ except DeliverdTimeoutError:
217
+ ... # you stopped waiting; the request is still open
218
+ except DeliverdError as err:
219
+ err.code # the API's own code: self_approval, rate_limited…
220
+ err.is_auth # 401 or 403
221
+ err.is_rate_limit # 429
222
+ err.is_not_found # 404
223
+ err.is_conflict # 409 — re-read and try again
224
+ err.hint # the one thing to do next
225
+ ```
226
+
227
+ Messages say what happened and what to do about it, rather than handing you a
228
+ code to search for.
229
+
230
+ ## Webhooks
231
+
232
+ ```python
233
+ from deliverd import construct_event, SIGNATURE_HEADER
234
+
235
+ @app.post("/webhooks/deliverd")
236
+ def receive():
237
+ event = construct_event(
238
+ secret=os.environ["DELIVERD_WEBHOOK_SECRET"],
239
+ body=request.get_data(as_text=True), # the RAW body
240
+ header=request.headers.get(SIGNATURE_HEADER),
241
+ )
242
+ if event.event == "approval.approved":
243
+ ...
244
+ ```
245
+
246
+ `body` must be the raw request body. A re-encoded object is a different string
247
+ and will never verify — in FastAPI that is `(await request.body()).decode()`, in
248
+ Django `request.body.decode()`.
249
+
250
+ `construct_event` raises rather than returning `None`, because a route that
251
+ treats an unverifiable body as "no event" answers 200 and tells whoever is
252
+ posting that everything is fine.
253
+
254
+ ## Lists
255
+
256
+ ```python
257
+ deliverd.approvals.list() # every one, following the cursor
258
+ deliverd.approvals.list(limit=20) # one page of twenty
259
+ page = deliverd.approvals.list_page(limit=20)
260
+ page.items, page.next_cursor
261
+ ```
262
+
263
+ **A `limit` means one page. No `limit` means all of them.** A full page and a
264
+ complete list are otherwise indistinguishable until somebody counts.
265
+
266
+ ## Publishing (maintainers)
267
+
268
+ Tag and push; GitHub Actions builds, re-runs the suite on Python 3.9, and uploads
269
+ through PyPI Trusted Publishing — there is no API token anywhere.
270
+
271
+ ```bash
272
+ git tag python-v0.1.0 && git push origin python-v0.1.0
273
+ ```
274
+
275
+ ## Where this is going
276
+
277
+ Named rather than left to be discovered:
278
+
279
+ - **No async client.** Everything blocks. If you are inside `asyncio`, run a
280
+ call in a thread (`await asyncio.to_thread(deliverd.approve, title=…)`) until
281
+ there is one.
282
+ - **`flows`, `comments` and `schedules` are not here yet.** Every primitive
283
+ takes `flow_id`, so a Python agent can join a flow; creating and completing
284
+ one is TypeScript, REST or MCP for now.
285
+
286
+ Both gaps are checked in this repository's own test suite, so they cannot
287
+ quietly become three.
288
+
289
+ ## Also
290
+
291
+ - **REST** — `docs/api.md`, and an OpenAPI document at `/openapi.json`
292
+ - **MCP** — a remote server any agent can connect to, at `deliverd.dev/api/mcp`
293
+ - **TypeScript** — [`@deliverd/sdk`](https://www.npmjs.com/package/@deliverd/sdk),
294
+ the same shapes with `await`
295
+ - **CLI** — `npm i -g deliverd`, for a build step
296
+
297
+ MIT.