toad-cli 0.4.0

A developer-friendly REST client called toad
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
# Getting Started With Toad

This tutorial builds a small collection of requests, one step at a time, against
[JSONPlaceholder](https://jsonplaceholder.typicode.com), a free fake REST API. By the end you'll have a collection
that reads data, sends data, chains requests together, checks response times, retries failures, and runs in CI.

It takes about 30 minutes. You need:

- toad installed (see [Installation]../README.md#installation)
- a terminal, and a second one for step 8
- an internet connection

The finished collection is in [`tutorial.toml`](tutorial.toml) if you want to compare as you go. Your times and
some response text will differ from the examples below.

## 1. Check That Toad Is Installed

```bash
toad -V
```

```
toad 0.4.0
```

Any version from 0.4.0 on has everything this tutorial uses.

## 2. Your First Request

Create a file called `tutorial.toml`:

```toml
[get-post]
url = "https://jsonplaceholder.typicode.com/posts/11"
```

Each table in the file is a request, and the table name (`get-post`) is the request's name. The method defaults to
`GET`.

Run it:

```bash
toad tutorial.toml
```

```
[get-post] 200 (92ms)
{
  "body": "delectus reiciendis molestiae occaecati non minima eveniet qui voluptatibus\naccusamus in eum beatae sit\nvel qui neque voluptates ut commodi qui incidunt\nut animi commodi",
  "id": 11,
  "title": "et ea vero quia laudantium autem",
  "userId": 2
}
```

The first line is the request name, the status code, and how long the request took. Below it is the response
body. JSON is pretty-printed.

## 3. Variables

Every request in this tutorial uses the same host. Put it in `[vars]` and refer to it with `{{base_url}}`:

```toml
[vars]
base_url = "https://jsonplaceholder.typicode.com"

[get-post]
url = "{{base_url}}/posts/11"
```

Run `toad tutorial.toml` again. The result is the same.

Variables work in the URL, query parameters, headers, the body, and `auth`. If you use a variable that isn't
defined, toad stops before sending the request instead of sending the literal text `{{name}}`.

## 4. Checking Results

So far toad reports the response but doesn't judge it. `expect_status` tells it which status codes count as
success. Add it to `get-post`, and add a request that is going to fail:

```toml
[vars]
base_url = "https://jsonplaceholder.typicode.com"

[get-post]
url = "{{base_url}}/posts/11"
expect_status = [200]

[missing-user]
url = "{{base_url}}/users/999999"
expect_status = [200]
```

```bash
toad tutorial.toml
echo "exit code: $?"
```

```
[get-post] 200 (84ms)
{
  ...
}
[missing-user] 404 (106ms)
{}
missing-user -> request 'missing-user' expected status [200] but got 404
exit code: 1
```

Toad stops at the first failed request and exits with code 1. When every request passes, it exits with 0. That
exit code is what makes toad useful in scripts and CI.

User 999999 doesn't exist, so a 404 is the right answer here. Change `missing-user` to expect it:

```toml
[missing-user]
url = "{{base_url}}/users/999999"
expect_status = [404]
```

Now both requests pass. `expect_status` takes a list, so `expect_status = [200, 204]` accepts either one.

Toad checks every setting name when it reads the file. If you misspell one, for example `expect_stauts`, it stops
before sending anything and tells you which request has the problem:

```
Error: could not parse tutorial.toml

Caused by:
    request 'missing-user': unknown field `expect_stauts`, expected one of `method`, `url`, `headers`, `query`, `body`, `body_file`, `interpolate_body`, `auth`, `expect_status`, `expect_max_ms`, `retry`, `retry_delay_ms`, `timeout_secs`, `capture`, `ignore_config`
```

To leave a note about a request, use a TOML comment. Anything after `#` is ignored:

```toml
[missing-user]
# User 999999 doesn't exist, so this should be a 404
url = "{{base_url}}/users/999999"
expect_status = [404]
```

## 5. Sending Data

Add two more requests: one with query parameters, and one that sends a JSON body.

```toml
[vars]
base_url = "https://jsonplaceholder.typicode.com"
user_id = "1"

[get-post]
url = "{{base_url}}/posts/11"
expect_status = [200]

[list-user-posts]
url = "{{base_url}}/posts"
expect_status = [200]

[list-user-posts.query]
userId = "{{user_id}}"
_limit = "2"

[create-post]
method = "POST"
url = "{{base_url}}/posts"
expect_status = [201]
body = '''
{
  "title": "Hello from toad",
  "body": "My first post",
  "userId": {{user_id}}
}
'''

[create-post.headers]
X-Request-Source = "toad-tutorial"

[missing-user]
url = "{{base_url}}/users/999999"
expect_status = [404]
```

A few things to notice:

- `[list-user-posts.query]` adds `?userId=1&_limit=2` to the URL. Toad handles the encoding.
- The body is checked to be valid JSON before it's sent, and toad sets `Content-Type: application/json` for you.
- The body uses a TOML single-quoted string (`'''`). Inside it, backslashes and quotes are taken literally, which
  is easier for JSON than a double-quoted string, where every `"` in the JSON would need escaping.
- `"userId": {{user_id}}` has no quotes around the placeholder, so the value goes in as a number.
- For a large body, put the JSON in its own file and use `body_file = "./create-post.json"` instead of `body`.
  The path is relative to the collection file.

JSONPlaceholder pretends to create the post and answers `201 Created` with an id of 101. It doesn't actually save
anything, so you can run this as often as you like.

## 6. Running Part of a Collection

List the requests in a file:

```bash
toad tutorial.toml -l
```

```
	get-post
	list-user-posts
	create-post
	missing-user
```

Run a single request by name:

```bash
toad tutorial.toml create-post
```

```
[create-post] 201 (130ms)
{
  "body": "My first post",
  "id": 101,
  "title": "Hello from toad",
  "userId": 1
}
```

Change how much toad prints with `-o`:

```bash
toad tutorial.toml -o quiet
```

```
[get-post] 200 (86ms)
[list-user-posts] 200 (115ms)
[create-post] 201 (212ms)
[missing-user] 404 (54ms)
```

- `-o quiet` prints one line per request.
- `-o verbose` also prints each request before it's sent: the method, URL, query parameters, headers, and body.
- `-o response-only` prints only response bodies, which is handy for saving one:
  `toad tutorial.toml get-post -o response-only > post.json`
- `-o silent` prints nothing. Only the exit code tells you how it went.

To change the default instead of passing `-o` every time, set `TOAD_OUTPUT`, for example
`export TOAD_OUTPUT=quiet`. See [Output Format](output_format.md) for every mode.

## 7. Authentication

Most real APIs need credentials. The `auth` setting builds the `Authorization` header for you. Add a token to
`[vars]` and use it on `create-post`:

```toml
[vars]
base_url = "https://jsonplaceholder.typicode.com"
user_id = "1"
token = "tutorial-token"

[create-post]
method = "POST"
url = "{{base_url}}/posts"
auth = "bearer {{token}}"
expect_status = [201]
# ... body and headers as before
```

This sends `Authorization: Bearer tutorial-token`. Use `auth = "basic {{user}}:{{pass}}"` for basic auth, and
toad base64-encodes it for you.

JSONPlaceholder ignores the header, so you can't see it working there. The next step shows how to check it.

To use the same credentials on every request, set `auth` once in a `[config]` table instead. See
[Authentication](auth.md), including how to keep real credentials out of files you commit.

## 8. Seeing What Toad Sends

Toad can also act as a server that prints every request it receives. That lets you see exactly what a request
looks like on the wire.

Add a profile to `tutorial.toml`. A profile is a named set of variables that replaces values in `[vars]` when you
select it with `-p`:

```toml
[profiles.listen]
base_url = "http://localhost:8080"
```

In a second terminal, start toad in listen mode:

```bash
toad --listen 8080
```

Back in the first terminal, send `create-post` to it:

```bash
toad tutorial.toml create-post -p listen
```

The listening terminal prints the request:

```
listening on port 8080
[2026-10-01T21:51:18.792302-06:00] POST /posts
Headers:
  x-request-source: toad-tutorial
  authorization: Bearer tutorial-token
  content-type: application/json
  content-length: 75
  accept: */*
  host: localhost:8080
Body:
{
  "body": "My first post",
  "title": "Hello from toad",
  "userId": 1
}
----------------------------------------
```

There's the `Authorization` header from step 7, the custom header from step 5, and the body with `{{user_id}}`
filled in.

The first terminal shows a failure:

```
[create-post] 200 (2ms)
OK
create-post -> request 'create-post' expected status [201] but got 200
```

That's expected. Listen mode always answers `200 OK`, and `create-post` expects `201`. You're only using listen
mode to look at the request, so the failure doesn't matter here.

Stop the listener with Ctrl+C when you're done.

## 9. Chaining Requests

Integration tests often need a value from one response in the next request: the id of something you just created,
or a token from a login. That's what captures are for.

Post 11 belongs to user 2. Capture its `userId` and use it to fetch the author. Change `get-post` and add
`get-author` after it:

```toml
[get-post]
url = "{{base_url}}/posts/11"
expect_status = [200]

[get-post.capture]
user_id = "$.userId"

[get-author]
url = "{{base_url}}/users/{{user_id}}"
expect_status = [200]

[get-author.capture]
author_name = "$.name"
author_email = "$.email"
```

`$.userId` is a [JSONPath](https://www.rfc-editor.org/rfc/rfc9535) query against the response body. After
`get-post` runs, `user_id` holds `2`, and every later request that uses `{{user_id}}` gets that value. That
includes `list-user-posts` and `create-post` from step 5.

Run it with `-o verbose` to see the captured values:

```bash
toad tutorial.toml -o verbose
```

```
[get-post] 200 (144ms)
...
captured:
  user_id = 2
[get-author] 200 (113ms)
...
captured:
  author_name = Ervin Howell
  author_email = Shanna@melissa.tv
```

Now run `get-author` on its own:

```bash
toad tutorial.toml get-author -o verbose
```

`get-post` doesn't run, so nothing is captured, and `{{user_id}}` falls back to `user_id = "1"` in `[vars]`. You
get user 1, Leanne Graham. Without that fallback, the request would fail:

```
get-author -> undefined variable 'user_id' (if this should be sent as literal text, write \{{user_id}})
```

Giving captured variables a default in `[vars]` keeps each request runnable on its own.

Captures can also read response headers (`"header:Location"`), the status code (`"status"`), or the whole body
(`"body"`). See [Variable Capture](variable_capture.md).

## 10. Time Limits

`expect_max_ms` fails a request that takes too long. To see a failure, give `get-post` a limit it can't meet:

```toml
[get-post]
url = "{{base_url}}/posts/11"
expect_status = [200]
expect_max_ms = 1
```

```bash
toad tutorial.toml get-post -o quiet
```

```
[get-post] 200 (86ms)
get-post -> request 'get-post' took 86ms, expected at most 1ms
```

Remove that line from `get-post`, and set a limit for every request in `[config]` instead:

```toml
[config]
expect_max_ms = 2000
```

A request can still set its own `expect_max_ms` to override the default.

Some machines and networks are slower than others. Rather than editing limits, scale them for a run with
`--time-scale 2` (every limit doubles) or turn them off with `--time-scale off`. The `TOAD_TIME_SCALE`
environment variable does the same thing. See [Response Time Assertions](response_time.md).

## 11. Retries

In shared test environments, a request sometimes fails for reasons that go away if you try again. `retry` handles
that. To see it, temporarily change `missing-user` so it fails, and give it two retries:

```toml
[missing-user]
url = "{{base_url}}/users/999999"
expect_status = [200]
retry = 2
retry_delay_ms = 500
```

```bash
toad tutorial.toml missing-user -o quiet
```

```
[missing-user] 404 (81ms)
retrying 'missing-user' in 500ms (attempt 2 of 3): request 'missing-user' expected status [200] but got 404
[missing-user] 404 (24ms)
retrying 'missing-user' in 500ms (attempt 3 of 3): request 'missing-user' expected status [200] but got 404
[missing-user] 404 (39ms)
missing-user -> request 'missing-user' expected status [200] but got 404 (after 3 attempts)
```

Run it again with `--retry off` and toad gives up after the first attempt.

Put `missing-user` back the way it was (`expect_status = [404]`, no `retry` lines). Then set a default for the
collection, and turn it off for `create-post`:

```toml
[config]
expect_max_ms = 2000
retry = 2
retry_delay_ms = 500

[create-post]
method = "POST"
url = "{{base_url}}/posts"
auth = "bearer {{token}}"
expect_status = [201]
retry = 0
# ... body and headers as before
```

Why `retry = 0` on `create-post`? If the first attempt reached the server but failed for another reason (it was
too slow, for example), retrying would create a second post. Turn retries off for any request that shouldn't be
sent twice. See [Retry on Failure](retry.md).

## 12. Running in CI

Toad exits with 0 when every request passes and 1 otherwise, so a CI job fails when a request does. A GitHub
Actions job that runs the collection:

```yaml
name: API smoke test
on: [push]

jobs:
  smoke-test:
    runs-on: ubuntu-latest
    env:
      TOAD_OUTPUT: quiet
      TOAD_TIME_SCALE: "2"
      TOAD_RETRY: "3"
    steps:
      - uses: actions/checkout@v4
      - run: cargo install toad-cli
      - run: toad doc/tutorial.toml
```

- `TOAD_OUTPUT: quiet` keeps the log to one line per request.
- `TOAD_TIME_SCALE: "2"` doubles every time limit, since CI runners are often slower than your machine.
- `TOAD_RETRY: "3"` replaces the `[config] retry` default for this run. `create-post` keeps its own `retry = 0`.

## The Finished Collection

```toml
# The finished collection from the toad getting started tutorial.
# See doc/getting_started.md.

[config]
expect_max_ms = 2000
retry = 2
retry_delay_ms = 500

[vars]
base_url = "https://jsonplaceholder.typicode.com"
user_id = "1"
token = "tutorial-token"

[profiles.listen]
base_url = "http://localhost:8080"

[get-post]
url = "{{base_url}}/posts/11"
expect_status = [200]

[get-post.capture]
user_id = "$.userId"

[get-author]
url = "{{base_url}}/users/{{user_id}}"
expect_status = [200]

[get-author.capture]
author_name = "$.name"
author_email = "$.email"

[list-user-posts]
url = "{{base_url}}/posts"
expect_status = [200]

[list-user-posts.query]
userId = "{{user_id}}"
_limit = "2"

[create-post]
method = "POST"
url = "{{base_url}}/posts"
auth = "bearer {{token}}"
expect_status = [201]
retry = 0
body = '''
{
  "title": "Hello from toad",
  "body": "My first post",
  "userId": {{user_id}}
}
'''

[create-post.headers]
X-Request-Source = "toad-tutorial"

[missing-user]
url = "{{base_url}}/users/999999"
expect_status = [404]
```

```bash
toad tutorial.toml -o quiet
```

```
[get-post] 200 (87ms)
[get-author] 200 (55ms)
[list-user-posts] 200 (58ms)
[create-post] 201 (114ms)
[missing-user] 404 (63ms)
```

## Things to Watch Out For

- **Every key in a request or in `[config]` must be a real setting.** A misspelling, or a key toad doesn't have
  (like `description`), stops toad before it sends anything. Use `#` comments for notes. See step 4.
- **Every top-level table is a request, apart from `[config]`, `[vars]`, and `[profiles]`.** A misspelled
  `[confg]` is read as a request named `confg`, and fails with ``missing field `url` (did you mean [config]?)``.
- **Requests run in file order, and captures only flow forward.** A request can use a value captured by a request
  above it, not below it.
- **Listen mode always answers `200 OK` with the body `OK`.** Use it with one request at a time, like
  `toad tutorial.toml create-post -p listen`. Running the whole collection against it stops at `get-post`, because
  `OK` isn't JSON and the capture fails. With the finished collection's `retry = 2`, toad tries three times first:

  ```
  [get-post] 200 (3ms)
  retrying 'get-post' in 500ms (attempt 2 of 3): capture 'user_id': response body is not JSON
  [get-post] 200 (2ms)
  retrying 'get-post' in 500ms (attempt 3 of 3): capture 'user_id': response body is not JSON
  [get-post] 200 (1ms)
  get-post -> capture 'user_id': response body is not JSON (after 3 attempts)
  ```

- **JSONPlaceholder doesn't save writes.** `create-post` returns id 101 every time, and `GET /posts/101` returns
  404. Against a real API, you would capture the new id and use it in later requests. The
  [Variable Capture]variable_capture.md#create-read-update-delete guide has that example.

## Where to Go Next

- [Variable Capture]variable_capture.md: login flows, `Location` headers, filters, pagination, and sending
  literal `{{...}}` text
- [Authentication]auth.md: basic auth, collection defaults, and keeping secrets out of committed files
- [Response Time Assertions]response_time.md: what's timed and how to set realistic limits
- [Retry on Failure]retry.md: what's retried, and how `--retry` and `TOAD_RETRY` interact with the file
- [Custom CA]custom_ca.md: calling servers whose certificates your system doesn't trust
- [Output Format]output_format.md: every output mode
- [Documentation index]README.md: every option and setting, with links