Skip to content

REST API

Every route of the HornetHive REST API for PostgreSQL: PostgREST-style filters, OR groups, ordering, counts, bulk inserts and upserts, updates, deletes, function calls, schemas, errors, and an OpenAPI 3.1 document for each database.

Endpoint and keys

Every table and view in your database is an endpoint at https://api.hornethive.io/v1/rest/{database id}/{table}. The database id is on its page in the dashboard. Each request carries an API key from Settings → API keys as Authorization: Bearer <key>.

A key has the read scope, or read and write, and can be limited to one database or to a list of IP addresses. A read key can be used from the browser, from the origins you register; keep write keys on your server. The syntax follows PostgREST, so tools and habits from it carry over; the differences are listed at the end.

The API used to be at https://hornethive.io/api/…. Until 7 November 2026 it still answers, with Deprecation and Sunset headers and a Link to the same call here; from that day (midnight, Malaysia time) it answers 410 with the new address. Drop the /api and change the host: hornethive.io/api/rest/… is api.hornethive.io/v1/rest/….

DB=<database id>
KEY=<api key>
curl "https://api.hornethive.io/v1/rest/$DB/books" -H "Authorization: Bearer $KEY"

Reading rows

GET /{table} returns an array of rows. select picks the columns, limit takes 1 to 1,000 rows (10 when left out), and offset skips rows for paging. GET /{table}/{key} returns one row by its primary key; a view, or a table without one, matches a column named id.

Lists are cached for 5 minutes and cleared by any write through the API. A write made straight to Postgres (psql, an ORM) is not seen by the cache, so a list can be up to 5 minutes old after one. The X-Cache header says HIT or MISS.

curl "https://api.hornethive.io/v1/rest/$DB/books?select=id,title,year&limit=20&offset=40" -H "Authorization: Bearer $KEY"
curl "https://api.hornethive.io/v1/rest/$DB/books/42" -H "Authorization: Bearer $KEY"

Filters

A filter is column=operator.value. Filters on different columns, and repeats of one column, must all match. A value with no operator is equality, so status=active still works as it always has.

OperatorMeansExample
eqequalsstatus=eq.active
neqnot equalstatus=neq.draft
gt, gtegreater than, or equalprice=gt.100
lt, lteless than, or equalage=lte.65
like, ilikepattern, ilike ignores case; * is the wildcardname=ilike.*ali*
match, imatchregular expression, imatch ignores casesku=match.^AB[0-9]+$
inone of a listid=in.(1,2,3)
isnull, true, false or unknowndeleted_at=is.null
cs, cdcontains, contained by (arrays, ranges, jsonb)tags=cs.{red,blue}
ovoverlaps (arrays, ranges)tags=ov.{red,blue}
fts, plfts, phfts, wftsfull-text search: to_tsquery, plain, phrase, websearchbody=wfts(english).hive hornet
not.negates any of the abovedeleted_at=not.is.null

In an in list or an or group, put double quotes around a value that holds a comma or a parenthesis: in.("Kuala Lumpur, MY",Ipoh). Values are sent to Postgres as parameters, never as SQL.

# between 10 and 50, not deleted, name containing "tea"
curl "https://api.hornethive.io/v1/rest/$DB/products?price=gte.10&price=lte.50&deleted_at=is.null&name=ilike.*tea*" \
  -H "Authorization: Bearer $KEY"

OR and nested conditions

or=(…) matches rows that pass any of its conditions, written column.operator.value and separated by commas. Groups nest with and(…) and or(…), and not. negates one: not.and=(…), or=(a.eq.1,not.and(b.gt.2,c.is.null)).

# under 18 or over 65, or a member whose plan is pro
curl "https://api.hornethive.io/v1/rest/$DB/people?or=(age.lt.18,age.gt.65,and(member.is.true,plan.eq.pro))" \
  -H "Authorization: Bearer $KEY"

Ordering, paging and counts

order=col.desc,other.asc sorts by one column or more; each takes asc or desc, and nullsfirst or nullslast. The older orderBy=col&order=desc still works.

Send Prefer: count=exact for the number of matching rows in the Content-Range header, as first-last/total: 0-19/240. Without it the total is *. Browsers can read the header from your registered origins.

curl -i "https://api.hornethive.io/v1/rest/$DB/orders?order=created_at.desc,id.desc&limit=20&offset=20" \
  -H "Authorization: Bearer $KEY" -H "Prefer: count=exact"
# Content-Range: 20-39/240

Inserting rows

POST /{table} with a JSON object inserts one row and returns it; with an array it inserts them all in one statement and returns an array. Each value takes its column's type: a JSON object fills a jsonb column, a JSON array a Postgres array, a string a timestamp or a bytea written as \x0102. Needs the write scope.

The columns are every key that appears in any row. A column that no row names takes its default; a column that some rows leave out is NULL in those rows, as in PostgREST, so send the same keys in every row when a column has a default. select picks the columns returned, and Prefer: return=minimal returns nothing.

curl -X POST "https://api.hornethive.io/v1/rest/$DB/books" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '[{"title":"Dune","year":1965},{"title":"Ubik","year":1969}]'

Upserts

Add Prefer: resolution=merge-duplicates to update the rows whose key already exists and insert the rest, or resolution=ignore-duplicates to leave existing rows alone. The key is the primary key; on_conflict=email names another unique column or columns. Only the columns you send are changed.

curl -X POST "https://api.hornethive.io/v1/rest/$DB/members?on_conflict=email" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -H "Prefer: resolution=merge-duplicates" \
  -d '[{"email":"[email protected]","plan":"pro"}]'

Updating and deleting

PATCH /{table} sets the columns in the JSON body on every row that matches the filters, and returns those rows. DELETE /{table} removes them and returns {"deleted": n}, or the rows themselves with Prefer: return=representation. Both take any filter above, and both refuse a request with no filter, so one call cannot change a whole table by accident.

curl -X PATCH "https://api.hornethive.io/v1/rest/$DB/orders?status=eq.pending&created_at=lt.2026-10-01" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"status":"expired"}'

curl -X DELETE "https://api.hornethive.io/v1/rest/$DB/sessions?expires_at=lt.now" -H "Authorization: Bearer $KEY" \
  -H "Prefer: return=representation"

Calling functions

POST /rpc/{function} calls a Postgres function with named arguments from the JSON body; GET /rpc/{function}?arg=value does the same read-only. A function that returns a set gives an array of rows, a composite type an object, a scalar its value, and void an empty 204.

A key without the write scope runs the function in a read-only transaction, as does every GET, so it cannot change data. Use functions for joins, aggregates and anything else a single table cannot express.

# CREATE FUNCTION top_books(n int) RETURNS SETOF books LANGUAGE sql STABLE
#   AS 'SELECT * FROM books ORDER BY rating DESC LIMIT n';
curl -X POST "https://api.hornethive.io/v1/rest/$DB/rpc/top_books" -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"n":5}'

Other schemas

Tables are read from the public schema. Accept-Profile: shop reads from another schema, and Content-Profile: shop writes to it, for tables and functions alike. Postgres's own schemas (pg_catalog, information_schema) are not served.

curl "https://api.hornethive.io/v1/rest/$DB/orders" -H "Authorization: Bearer $KEY" -H "Accept-Profile: shop"

Raw SQL

POST /query runs one SQL statement with $1, $2… parameters and returns {"columns": [...], "rows": [...]}. A key without the write scope runs it in a read-only transaction.

curl -X POST "https://api.hornethive.io/v1/rest/$DB/query" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"query":"SELECT author, count(*) FROM books WHERE year > $1 GROUP BY author","params":[2000]}'

What values come back as

Integers, floats and booleans are JSON numbers and booleans. Timestamps and dates are ISO 8601 strings. numeric is a string, so no digit is lost. json and jsonb come back as a JSON string to parse; arrays in their Postgres text form, such as {red,blue}; bytea as hex. The OpenAPI document below states each column's type.

Errors

A refused request answers {"error": "…"}. When Postgres refused the statement the body also has code, its SQLSTATE, and details and hint when it gave them.

400 is a bad request or statement, 401 a missing or wrong key, 403 a key without the scope or for another database (or a privilege Postgres refused), 404 no such database, table, function or row, 409 a unique or foreign key conflict, and 429 the rate limit.

{"error":"write failed: pq: duplicate key value violates unique constraint \"members_pkey\"",
 "code":"23505","details":"Key (email)=([email protected]) already exists."}

OpenAPI

GET /openapi.json returns an OpenAPI 3.1 document for your database, built from its schema when you ask: every table with its columns and types, which ones are required on insert, the primary keys, and every function with its arguments. With Accept-Profile it describes another schema. Load it in Swagger UI, Postman or Insomnia, or generate a typed client with openapi-generator or openapi-typescript.

The same operations for any database, with no key needed, are at https://api.hornethive.io/v1/rest/openapi.json.

curl "https://api.hornethive.io/v1/rest/$DB/openapi.json" -H "Authorization: Bearer $KEY" -o openapi.json
npx openapi-typescript openapi.json -o hive.d.ts

From JavaScript

@hornetcloud/hive-js wraps all of this: every filter above, or(), getWithCount(), bulk insert(), upsert(), rpc(), schema() and openapi().

Differences from PostgREST

  • No embedded relations (select=*,orders(*)): join in a view or a function. The GraphQL API does not follow foreign keys either.
  • No JSON paths in select or filters (data->>name), and no aliases or casts in select.
  • limit is 1 to 1,000 and 10 by default; PostgREST has no default. Page with offset and the count.
  • No Range request header, no CSV or single-object responses, and only count=exact.
  • A value with no operator is equality, where PostgREST refuses it.
  • Lists are cached for up to 5 minutes, as above; PostgREST does not cache.
  • Every key acts as the database's owner. Row-level security has no per-user role to act on, so keep write keys on your server.
  • POST /query runs SQL, which PostgREST does not offer.