REST
REST API v1
A JSON API, authenticated with bearer API keys, for links, Markdown pages, files, images, collections, Notion mirrors, bio pages, and HTML artifacts.
Authentication
Create an API key on the API keys page in the dashboard. Bidama shows the full key once, right after you create it, and only stores a hash, so copy it somewhere safe. Send the key as a bearer token on every request. The only route that works without a key is the OpenAPI download.
Authorization: Bearer bdma_<key>
Each key carries one or more scopes. Scopes don't include each other, so a key with only write can't call GET routes. Give a key every scope it needs.
| Scope | What it allows |
|---|---|
read | Every GET route, plus the two link simulation routes, which never change anything. |
write | Creating, updating, uploading, redeploying, restoring, and queueing jobs. |
delete | Deleting resources, collection items, bio media, and form submissions, and revoking upload requests. |
If authentication fails you get one of these codes:
invalid_api_key(401): the key is missing, malformed, unknown, or revoked.user_disabled(403): the account that owns the key has been disabled.insufficient_scope(403): the key is valid but doesn't have the scope this route needs.
Quickstart
Check that your key works, then create a short link:
curl -sS \
-H "Authorization: Bearer $BIDAMA_API_KEY" \
https://<your-host>/api/v1/me
curl -sS \
-H "Authorization: Bearer $BIDAMA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"link","target_url":"https://example.com","title":"Example"}' \
https://<your-host>/api/v1/resources
The response is the new resource, including its slug and public url.
How the API behaves
- Your data only. Every route works on resources you own. A slug that doesn't exist, or a resource you deleted, returns
404 not_found. A resource that belongs to another account returns403 forbiddenon the resource routes and404elsewhere. Your own expired resources stay readable and editable through the API. - Errors. Failures return JSON with a readable
errormessage and a stableerror_code. Validation errors usually name thefieldas well. Match onerror_code, not the message. - Rate limits. Every request counts against a budget for your IP address, and requests with a valid key also count against a budget for that key. Both default to 100 requests a minute, so several keys used from one machine share the IP budget. Creating links through
POST /api/v1/resourceshas an extra limit of 60 an hour by default. Responses includeX-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Reset. Going over returns429 rate_limitedwith aRetry-Afterheader. - Body size. Plain JSON requests can be up to 1 MiB. Uploads follow the server's upload limit (50 MiB unless the operator changed it), and HTML artifacts have their own limits (see below).
- Safe edits. Reads and writes of a single resource return an
X-Resource-ETagheader. Send that value back unchanged asX-Resource-If-Matchwhen you update the resource. If someone changed it in the meantime you get412 etag_mismatchinstead of silently overwriting their edit. Collection item routes useX-Collection-ETagandX-Collection-If-Matchin the same way. The standardIf-Matchheader still works, but new clients should use theX-headers, because proxies can rewrite a normalETag. - Pagination.
GET /resourcesandGET /jobsreturnnext_cursorwhen there are more results; pass it back as?cursor=. The submission lists takelimitandoffsetand return atotal. Upload requests and webhooks come back as a single list. - Analytics. Your own API calls never count as views of your resources.
Endpoints
This is every route under /api/v1, with the scope it needs. The OpenAPI document has the same routes with full request and response schemas, which is handy for generating a client.
Account
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/me | read | Your profile, storage usage and quota, which resource types you may create, and server features such as features.artifacts_enabled. |
GET | /api/v1/openapi.yaml | none | The OpenAPI 3.1 document. Public, and rate limited by IP address. |
AI agents can use the same features through the MCP server, which signs in with OAuth instead of API keys.
Resources
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/resources | read | List your resources. Filter with type, q, label, archived, pinned, and upload_request; order with sort; page with limit, offset, or cursor. |
GET | /api/v1/resources/{slug} | read | Get everything you can edit on one resource, plus its X-Resource-ETag. |
POST | /api/v1/resources | write | Create a link, Markdown page, collection, Notion mirror, or bio page from JSON. |
POST | /api/v1/resources/bulk-create | write | Create up to 200 links in one call. Each item succeeds or fails on its own. Requires an Idempotency-Key header. |
POST | /api/v1/resources/import | write | Import links from a Bitly or Linktree CSV export. Requires an Idempotency-Key header. |
PATCH | /api/v1/resources/{slug} | write | Change only the fields you send and get back the new slug and url. Taking a slug that is already in use returns 409 slug_taken. Visibility can't be changed after creation. |
DELETE | /api/v1/resources/{slug} | delete | Delete a resource. Its public URL stops working right away. |
GET | /api/v1/resources/{slug}/qr | read | A QR code for the public URL. format is png (the default) or svg; size is in pixels, 512 by default, between 128 and 2048. |
GET | /api/v1/resources/{id}/preview | read | A small preview image, as linked from the list response. It never counts as a public view. |
POST | /api/v1/links/simulate | read | Try out redirect rules and A/B variants for a link you haven't saved yet. Nothing is recorded. |
POST | /api/v1/resources/{slug}/link/simulate | read | The same dry run for a saved link. Fields you leave out keep their saved values. |
POST | /api/v1/uploads | write | Upload a file. Bidama checks the actual content against a list of allowed types and creates an image or a file resource. |
HTML artifacts
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/artifacts | write | Publish a static site from a ZIP file or a set of individual files. |
POST | /api/v1/resources/{slug}/artifact/deployments | write | Publish a new version, either a full replacement or a patch that only uploads the files that changed. |
GET | /api/v1/resources/{slug}/artifact/files | read | List the files in the current version and whether each one can be edited as text. |
GET | /api/v1/resources/{slug}/artifact/source | read | Read one text file (up to 1 MiB) from the current version. Pass its path in the query string. |
Collections
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/resources/{slug}/collection/items | write | Add a copy of one of your file or image resources to the collection. |
PATCH | /api/v1/resources/{slug}/collection | write | Reorder items and change their display names and captions in one call. |
DELETE | /api/v1/resources/{slug}/collection/items/{itemID} | delete | Remove one item from the collection. |
Bio pages
| Method | Path | Scope | Description |
|---|---|---|---|
PATCH | /api/v1/resources/{slug}/bio | write | Replace the whole profile and its pages and blocks. pages is required, and profile fields you leave out are cleared. |
POST | /api/v1/resources/{slug}/bio/avatar | write | Upload or replace the profile picture. |
DELETE | /api/v1/resources/{slug}/bio/avatar | delete | Remove the profile picture. |
POST | /api/v1/resources/{slug}/bio/cover | write | Upload or replace the cover image. |
DELETE | /api/v1/resources/{slug}/bio/cover | delete | Remove the cover image. |
POST | /api/v1/resources/{slug}/bio/pages/{pageID}/blocks/file | write | Upload a file and add it to a page as a new image or file block. |
POST | /api/v1/resources/{slug}/bio/blocks/{blockID}/file | write | Replace the file behind an existing image or file block. |
POST | /api/v1/resources/{slug}/bio/blocks/{blockID}/thumbnail | write | Upload or replace a block's thumbnail. |
GET | /api/v1/resources/{slug}/bio/form-submissions | read | List what visitors sent through the page's forms. |
GET | /api/v1/resources/{slug}/bio/form-submissions/export | read | Download form submissions as CSV. Cells are escaped so spreadsheets won't run them as formulas. |
DELETE | /api/v1/resources/{slug}/bio/form-submissions/{submissionID} | delete | Permanently delete one form submission. |
Markdown versions
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/resources/{slug}/markdown/versions | read | List earlier versions of a Markdown page, newest first. Bidama keeps the last 50. |
GET | /api/v1/resources/{slug}/markdown/versions/{versionID} | read | Get the full source of one version. |
POST | /api/v1/resources/{slug}/markdown/versions/{versionID}/restore | write | Make an earlier version current again. The text it replaces is saved as a version too, so a restore can be undone. |
Notion and background jobs
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/resources/{slug}/file/optimize-original | write | Queue a job that replaces a large JPEG with a smaller one, or converts HEIC or HEIF to JPEG. This changes the stored original for good. |
POST | /api/v1/resources/{slug}/notion/refresh | write | Queue a refresh of the whole Notion mirror, or of a single page. |
GET | /api/v1/resources/{slug}/notion/pages | read | List the cached Notion pages and their refresh status. |
GET | /api/v1/resources/{slug}/notion/pages/{page_id} | read | Get one cached page, including its rendered HTML. |
GET | /api/v1/resources/{slug}/notion/assets | read | List the images and files cached from Notion, with their public URLs. |
GET | /api/v1/jobs | read | List your background jobs. Filter by status and type. |
GET | /api/v1/jobs/{id} | read | Check one job: its status, result or error, attempts, and how long to wait before checking again. |
POST | /api/v1/jobs/{id}/retry | write | Run a failed or skipped job again. This creates a new job. |
Routes that queue work answer 202 Accepted with a job_id, a poll_url, and retry_after_seconds. Poll the job until it finishes. If the same work is already queued you get 409 job_already_active.
Upload requests
An upload request is a private link that lets other people send you files without an account.
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/upload-requests | read | List your upload requests. |
POST | /api/v1/upload-requests | write | Create an upload request. The response is the only time you see its secret token. |
GET | /api/v1/upload-requests/{id} | read | Get one upload request and its status. |
PATCH | /api/v1/upload-requests/{id} | write | Change the fields you send. An empty password or expires_at clears it. |
POST | /api/v1/upload-requests/{id}/status | write | Set the status to open, paused, or completed. |
POST | /api/v1/upload-requests/{id}/rotate-token | write | Replace the token. The old link stops working, and the new token is shown once. |
GET | /api/v1/upload-requests/{id}/submissions | read | List the submissions people have sent. |
GET | /api/v1/upload-requests/{id}/submissions/{submissionID} | read | Get one submission and the files it created. |
PATCH | /api/v1/upload-requests/{id}/submissions/{submissionID} | write | Mark a submission as unreviewed, accepted, needs_changes, or rejected. |
POST | /api/v1/upload-requests/{id}/revoke | delete | Shut the upload request down for good. |
Analytics
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/analytics/resources/{slug} | read | Views and visitors for one resource: a daily series plus top browsers, systems, referrers, UTM tags, countries, and link destinations. |
GET | /api/v1/analytics/resources/{slug}/export | read | Download one resource's analytics as CSV. |
GET | /api/v1/analytics/export | read | Download analytics for all your resources as CSV. |
The summary covers all time unless you pass days (1 to 365) or both from and to as YYYY-MM-DD. Exports cover the last 30 days unless you pass from and to. An export stops at 100,000 rows, and its X-Bidama-Truncated header tells you whether it did.
Webhooks
| Method | Path | Scope | Description |
|---|---|---|---|
GET | /api/v1/webhooks | read | List your webhook endpoints. |
POST | /api/v1/webhooks | write | Add an HTTPS endpoint. The response is the only time you see its signing secret. |
PATCH | /api/v1/webhooks/{id} | write | Change the endpoint's name, URL, events, or enabled state. |
POST | /api/v1/webhooks/{id}/rotate-secret | write | Issue a new signing secret, shown once. |
POST | /api/v1/webhooks/{id}/enable | write | Turn delivery back on and reset the failure count. |
POST | /api/v1/webhooks/{id}/disable | write | Pause delivery. |
POST | /api/v1/webhooks/{id}/test | write | Send a signed test event. |
GET | /api/v1/webhooks/{id}/deliveries | read | See recent deliveries and why any of them failed. |
You can subscribe to resource.created, resource.updated, resource.deleted, resource.expired, link.clicked, upload_request.submitted, and upload_request.reviewed. Click events are sent only for links with webhook_clicks turned on. Each delivery is signed. Check X-Bidama-Webhook-Signature, which is v1= followed by the hex HMAC-SHA256 of timestamp + "." + body, using the timestamp from X-Bidama-Webhook-Timestamp.
Creating resources
POST /api/v1/resources takes a JSON body, and the type field decides which other fields apply:
type |
Required | Optional |
|---|---|---|
link | target_url | max_views, redirect_rules, test_variants, fallback_url, webhook_clicks, og_title, og_description, og_image_url |
markdown | body_markdown (or raw_markdown) | max_views |
collection | none | layout, settings |
notion | source_url (or fetch_url) | root_page_id, auto_refresh, refresh_interval_seconds |
bio | none | theme, settings (with display_name, headline, bio), root_page_slug, root_page_title |
Every type also accepts slug, title, description, visibility, password, starts_at, expires_at, and labels. A successful create returns 201 with the same body you would get from GET /api/v1/resources/{slug}.
- Slugs are 3 to 64 characters of lowercase letters, digits, hyphens, and underscores. Leave out
slugand Bidama picks a random 8-character one. visibilityispublic(the default),unlisted, orprivate, and can't be changed later. Timestamps are RFC 3339, andstarts_atmust come beforeexpires_at.- You can add up to 20 labels of lowercase letters, digits, hyphens, and underscores.
- Files and images go through
POST /api/v1/uploadsasmultipart/form-data, with the file in a part calledfileand the common fields alongside it (exceptstarts_at).max_viewsworks for files but not images. Addtype=imageortype=fileif you want the upload rejected when the content turns out to be the other kind. - Creating a Notion mirror also queues its first refresh. If that can't be queued, nothing is created.
HTML artifacts
An artifact is a static website: HTML, CSS, JavaScript, images, and so on. Its share link looks like any other, https://<host>/<slug>. After Bidama checks the password, expiry, and visibility, it sends the visitor to the site on its own isolated origin. Artifacts need to be turned on by the server operator; check features.artifacts_enabled in GET /api/v1/me.
Send multipart/form-data with a text part named metadata that holds a JSON object:
{
"slug": "optional-slug",
"title": "Optional title",
"description": "Optional description",
"visibility": "public",
"password": "optional password",
"expires_at": "2026-08-01T00:00:00Z",
"labels": ["demo"]
}
Then add the site's files in one of two ways. You can't mix them.
- As a ZIP. Add one file part named
archive. - As separate files. List them in
metadata.filesas{"part": "f0", "path": "index.html"}entries, then add one file part per entry under the name you gave it inpart. The path comes frommetadata; the uploaded file name is ignored.
The entry page is index.html at the root of the site. If there isn't one, Bidama uses the first .html or .htm file. Either way the page can't be empty. Paths must be plain relative paths: no .., no backslashes, and no two paths that differ only in letter case.
Paths starting with .auth or _bidama are reserved. By default a site can be up to 100 MiB in total, with at most 500 files, no file over 50 MiB, and a ZIP of at most 50 MiB. Your server may set lower limits. max_views isn't supported for artifacts.
To publish a new version, post to /api/v1/resources/{slug}/artifact/deployments. You can send the whole site again the same way, or send a patch: set "mode": "patch" and the current base_deployment_id, upload only the files that changed, and list removed files in delete_paths. Each new version gets a new origin, and the share link always points to the latest one.
Errors
Every error has the same shape:
{
"error": "Enter a valid URL.",
"error_code": "validation_failed",
"field": "target_url"
}
These are the codes you're most likely to see:
error_code |
HTTP | Meaning |
|---|---|---|
invalid_api_key | 401 | The key is missing, malformed, unknown, or revoked. |
insufficient_scope | 403 | The key doesn't have the scope this route needs. |
forbidden | 403 | The resource belongs to another account. |
not_found | 404 | Nothing with that slug or ID exists, or it was deleted. |
slug_taken | 409 | Another resource already uses that slug. |
idempotency_conflict | 409 | You reused an Idempotency-Key with a different request body. |
job_already_active | 409 | The same background job is already queued or running. |
etag_mismatch | 412 | The resource changed since you read it. Fetch it again and retry. |
payload_too_large | 413 | The request body or upload is over the size limit. |
rate_limited | 429 | Too many requests. Wait for the number of seconds in Retry-After. |
quota_exceeded | 409 | This would take you over your storage quota. |
validation_failed | 400 | A field is invalid. Look at field to see which one. |
request_body_invalid | 400 | The body isn't valid JSON or multipart data. |
unsupported_media | 415 | The uploaded file type isn't allowed. |
feature_disabled | 403 | Your account isn't allowed to use this feature. |
service_unavailable | 503 | The server is busy or a dependency is down. Retry later. |
artifacts_disabled | 503 | HTML artifacts are turned off on this server. |
artifact_archive_invalid | 400 | The ZIP is damaged, encrypted, or expands too much. |
artifact_path_invalid | 400 | A file path is invalid, reserved, or clashes with another path. |
artifact_index_missing | 400 | There's no usable HTML entry page, or the root index.html is empty. |
artifact_too_large | 413 | The site, the ZIP, or one file is over a size limit. |
artifact_too_many_files | 413 | The site has more files than allowed. |
See also: MCP server · OpenAPI document