Build
Queries
Rows live at /v1/apps/{app}/tables/{table}/rows. Filters follow the PostgREST style, so done=eq.false means "where done is false".
Example table
This page uses one notes table:
berth tables create --app "$APP" notes title:text:notnull slug:text:unique done:boolean priority:integer 'tags:text[]'Bulk insert
Send an array to insert up to 1,000 rows in one request. It is one transaction: all rows go in or none do.
curl -sS -X POST https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '[
{"title":"Buy milk","slug":"milk","done":false,"priority":2,"tags":["home","shop"]},
{"title":"Buy bread","slug":"bread","done":true,"priority":1,"tags":["shop"]},
{"title":"Fix the sink","slug":"sink","done":false,"priority":3,"tags":["home"]},
{"title":"Read a book","slug":"book","done":false},
{"title":"File taxes","slug":"taxes","done":false,"priority":3}
]' | jq '.rows | length'A single object also works and returns "row" as well as "rows":
ROW_ID=$(curl -sS -X POST https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"title":"Walk the dog","slug":"dog","priority":1}' | jq -er .row.id)Filters
Each filter is column=op.value. Several filters are ANDed. Using curl -G --data-urlencode keeps characters like *, (, and [ safe.
| Op | Meaning | Example |
|---|---|---|
eq, neq | equal, not equal | done=eq.false |
gt, gte, lt, lte | comparisons | priority=gte.2 |
like, ilike | pattern, * is the wildcard; ilike ignores case | title=ilike.*milk* |
in | one of a list | slug=in.(milk,bread) |
is | null, true, false | priority=is.null |
cs | array or jsonb contains, JSON value | tags=cs.["home"] |
not. | negates any op | done=not.eq.true |
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
--data-urlencode "done=eq.false" \
--data-urlencode "priority=gte.2" | jq -c '.rows[] | .title'curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
--data-urlencode "title=ilike.*BUY*" | jq -c '.rows[] | .title'
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
--data-urlencode "slug=in.(milk,bread)" | jq -c '.rows[] | .title'
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
--data-urlencode 'tags=cs.["home"]' | jq -c '.rows[] | .title'
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
--data-urlencode "priority=is.null" | jq -c '.rows[] | .title'
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
--data-urlencode "done=not.eq.true" | jq -c '.rows[] | .title'The CLI takes the same filters as --where column.op=value:
berth rows list --app "$APP" notes --where done.eq=false --where priority.gte=2There is no or= filter. For OR logic use in.(...) when it is one column, or SQL.
Select and order
select picks columns. order takes column.asc or column.desc, comma separated; nulls sort last. limit is 1 to 1000 (default 100), and offset skips rows.
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
--data-urlencode "select=id,title,priority" \
--data-urlencode "order=priority.desc,title.asc" \
--data-urlencode "limit=3"berth rows list --app "$APP" notes --select title,priority --order priority.desc --limit 3 --offset 1Count
count=exact adds "count" to the body and an X-Total-Count header, with the filters applied and before limit.
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
--data-urlencode "done=eq.false" \
--data-urlencode "count=exact" \
--data-urlencode "limit=1" | jq .count
berth rows list --app "$APP" notes --count --limit 1Cursor pagination
Offsets get slow and skip rows when data changes. Cursors do not. Each page returns next_cursor (also in the Berth-Next-Cursor header) while there is more; pass it back as cursor with the same filters and order.
CURSOR=""
while :; do
PAGE=$(curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
--data-urlencode "limit=2" \
${CURSOR:+--data-urlencode "cursor=$CURSOR"})
echo "$PAGE" | jq -c '[.rows[].title]'
CURSOR=$(echo "$PAGE" | jq -r '.next_cursor // empty')
[ -n "$CURSOR" ] || break
doneThe CLI follows cursors for you with --all, or takes one page at a time with --cursor:
berth rows list --app "$APP" notes --limit 2 --allOne row by id
curl -sS https://api.atberth.com/v1/apps/$APP/tables/notes/rows/$ROW_ID \
-H "Authorization: Bearer $SECRET_KEY"
curl -sS -X PATCH https://api.atberth.com/v1/apps/$APP/tables/notes/rows/$ROW_ID \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"done":true}'berth rows get --app "$APP" notes "$ROW_ID"
berth rows update --app "$APP" notes "$ROW_ID" priority=2
berth rows rm --app "$APP" notes "$ROW_ID"Update and delete by filter
PATCH and DELETE on /rows act on every row that matches. At least one filter is required, so a missing filter cannot wipe a table.
curl -sS -X PATCH "https://api.atberth.com/v1/apps/$APP/tables/notes/rows?slug=in.(milk,bread)" \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"priority":1}' | jq '.rows | length'berth rows update --app "$APP" notes --where slug.eq=sink done=true
berth rows delete --app "$APP" notes --where done.eq=true --yesWith curl, DELETE /rows?done=eq.true returns {"deleted": n}.
Upsert
Add upsert=true&on_conflict=column to insert or update by a unique column. The response is 200 with the resulting rows.
curl -sS -X POST "https://api.atberth.com/v1/apps/$APP/tables/notes/rows?upsert=true&on_conflict=slug" \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '[{"slug":"milk","title":"Buy oat milk","done":false},{"slug":"eggs","title":"Buy eggs","done":false}]'berth rows upsert --app "$APP" notes --on-conflict slug slug=milk title="Buy oat milk" priority=3Import CSV or NDJSON
Import up to 20 MB into an existing table. CSV needs a header row with column names. It is all or nothing: one bad row and nothing is inserted.
cat > notes.csv <<'EOF'
title,slug,priority
Plan the trip,trip,2
Pay rent,rent,3
EOF
berth import --app "$APP" notes notes.csvprintf '%s\n' '{"title":"Book flights","slug":"flights"}' '{"title":"Renew passport","slug":"passport","priority":3}' > notes.ndjson
curl -sS -X POST https://api.atberth.com/v1/apps/$APP/tables/notes/import \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/x-ndjson" \
--data-binary @notes.ndjsonSend CSV to the same route with Content-Type: text/csv. The result is {"inserted": n}.
Safe retries with Idempotency-Key
Phones lose signal mid request. Send an Idempotency-Key on POST, PATCH, PUT, or DELETE, and a retry with the same key and body gets the stored response instead of a second insert. Keys are kept 24 hours per credential. Replays carry Idempotent-Replayed: true. The same key with a different body is 409 idempotency_conflict.
for attempt in 1 2; do
curl -sS -o /dev/null -D - -X POST https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: note-once-$APP" \
-d '{"title":"Only once","slug":"once"}' | grep -i -E '^(HTTP|idempotent-replayed)'
done
berth rows list --app "$APP" notes --where slug.eq=once --count