# Dbrij Forms Documentation

Everything you need to use Dbrij Forms from outside the dashboard: links and prefill, embedding, the public form API, spam protection, signed webhooks, the live spreadsheet feed, exports, custom domains and plan limits.

Canonical page: https://dbrij.com/products/forms/docs

## Introduction

**Dbrij Forms** builds forms whose answers become work: a task with an owner, a lead, a ticket, a candidate. Forms take files, payments, signatures and approvals, confirm who is answering, collect with no signal in the mobile app, and speak more than one language.

This reference covers everything that touches a form from outside the dashboard: its public link and prefill, embedding it in your own site, the public API the form page itself uses, spam protection, webhooks, the live spreadsheet feed, exports, custom domains and the limits of each plan.

Public form API`https://api.dbrij.com/api/v1/public/forms`

The public API needs no key: it is what the hosted page and the embed call for somebody with no account. JSON responses come inside an envelope, `{ "success": true, "data": … }`; errors come as `{ "success": false, "statusCode": 400, "message": "…" }`, and the message is written to be shown to the person answering. The spreadsheet feed is plain CSV.

## Form links and prefill

Every published form has a public address, `/f/<slug>` on Dbrij, such as `https://app.dbrij.com/f/job-application`. Copy the exact link from the form's Share panel. A form that has closed (past its closing time, or full) still opens and says why, so somebody holding a printed flyer is not told the company does not exist. A form that has never been published answers 404.

### Prefill with query parameters

Add a question's key as a query parameter and the question starts with that answer. The key is shown on each question in the editor.

Prefilled link

```
https://app.dbrij.com/f/job-application?full_name=Ada%20Obi&role=engineer&skills=design,research&ref=newsletter
```

- Visible questions take a value from the link only while the form allows prefill, which it does unless you turn it off in the form's settings.
- Hidden fields always read the parameter named in their own "prefill from" setting, whatever the prefill setting says. Use them for campaign tags and referral codes.
- A choice question takes only one of its own options. A question with several answers takes them comma separated, keeping only the ones it offers.
- Consent, matrix, ranking, file, signature and calculated questions are never filled from a link. Text is cut to 2,000 characters.
- A prefilled value never replaces something the person has already typed, and answers brought back by a resume link win over the link's values.

### Embedded view

`?embed=1` drops the page's outer chrome for use inside a frame, and makes the form tell its parent page how tall it is (see Embedding).

### Finish later

When a form has save and resume switched on, the person can save their answers and come back from a link, `/f/<slug>?resume=<token>`, optionally sent to their email. A draft can be finished for 30 days after its last save. Drafts are never counted, routed or billed as responses. Anyone with the link can see the saved answers, so the email says to keep it private.

### Password protected forms

A form with a password shows its name and who is asking, and none of its questions, until the password is given. The page trades the password for an access token that lasts six hours (see `POST /:slug/unlock`) and keeps it in the browser. An integration passes the token as `?access=<token>` when reading the form and as `accessToken` when saving or submitting.

## Embedding

Paste one script tag where the form should appear. By default it renders a button; pressing it opens the form over your page in a centred window. The form itself loads only when somebody asks for it. The Share panel gives you the tag with your form's slug filled in.

A button that opens the form

```
<script src="https://app.dbrij.com/forms-embed.js" data-form="job-application" data-label="Apply now" async></script>
```

The form inside your page

```
<script src="https://app.dbrij.com/forms-embed.js" data-form="job-application" data-mode="inline" data-height="720" async></script>
```

ATTRIBUTES

|  |  |  |
| --- | --- | --- |
| data-form* | string | The form's slug: 4 to 64 letters, digits, underscores or hyphens. |
| data-label | string | The button's words, and the window's accessible name. Default "Open form". |
| data-color | string | The button's colour, such as #0ea5e9. Default Dbrij red. |
| data-mode | "inline" | Put the form itself in the page instead of a button. It grows to fit as the form reports its height. |
| data-height | number | The inline form's height in pixels until the first height message arrives. Default 640. |

The tag is small and has no dependencies: no globals, inline styles only, and every element it adds is prefixed `dbrij-form`. Several tags on one page each render their own form, and each tag runs once. The window closes on Escape, on a click outside it, on its close button, and on a close message from the form.

### Messages the form posts

Framed with `?embed=1`, the form page posts messages to its parent with `postMessage`. The embed believes a message only when it comes from its own script's origin and from the frame it made, so another frame on your page cannot resize or close it. Check both too if you listen yourself.

|  |  |  |
| --- | --- | --- |
| dbrij-form-height | { type, height } | Sent whenever the form's height changes, in pixels. The inline embed sets the frame to it, kept between 120 and 20,000. |
| dbrij-form-close | { type } | The popup window closes when the framed page posts it. A form opened in the popup offers a Close button on its thank you page that posts it; a page of your own framed the same way can post it too. |

Your own frame, sized by the form

```
<iframe id="dbrij-form-job-application" src="https://app.dbrij.com/f/job-application?embed=1" title="Job application"
  style="width:100%;height:640px;border:0" loading="lazy"></iframe>
<script>
window.addEventListener("message", function (e) {
  var f = document.getElementById("dbrij-form-job-application");
  if (!f || e.origin !== "https://app.dbrij.com" || e.source !== f.contentWindow) return;
  if (e.data && e.data.type === "dbrij-form-height" && e.data.height > 0) f.style.height = e.data.height + "px";
});
</script>
```

## Public form API

These are the calls the hosted page and the embed make. Use them to put a form behind your own interface. No key is needed; each route has its own limit of requests a minute from one address, and going over it answers `429`.

Base`https://api.dbrij.com/api/v1/public/forms`

### Read a form

GET`/public/forms/:slug`

The form as the page renders it: its settings, its questions (never the right answers or hidden formulas), branding and whether it is closed. 60 a minute.

QUERY

|  |  |  |
| --- | --- | --- |
| access | string | The token from unlock, for a password protected form. Without it a locked form comes back with passwordRequired: true and no questions. |
| resume | string | A resume token. The saved answers come back as resumed, while the draft is under 30 days old. |

Response data (shortened)

```
{
  "slug": "job-application",
  "name": "Job application",
  "description": "Tell us about yourself.",
  "companyName": "Acme",
  "schema": { "pages": [ { "id": "p1", "fields": [ { "key": "full_name", "type": "short_text", "label": "Your name", "required": true } ] } ] },
  "settings": { "allowPrefill": true, "saveAndResume": true, "welcome": null, "thankYou": null, "translations": {}, "quiz": null },
  "passwordRequired": false,
  "captchaSiteKey": null,
  "requireVerification": false,
  "verificationChannel": "email",
  "payment": null,
  "resumed": null,
  "badge": true,
  "closedReason": null
}
```

### Unlock a password protected form

POST`/public/forms/:slug/unlock`

Trades the password for an access token that opens this one form for six hours. 5 a minute, because it is the one thing that can be guessed. A wrong password answers 400 and never says how close it was.

Body

```
{ "password": "spring-intake" }
```

Response data

```
{ "accessToken": "…" }
```

### Save answers to finish later

POST`/public/forms/:slug/progress`

Only on a form with save and resume switched on. Saving again with the same resumeToken updates the draft. 20 a minute.

|  |  |  |
| --- | --- | --- |
| answers* | object | Answers so far, keyed by question key. |
| resumeToken | string | The draft to update. Leave it out to start one. |
| email | string | Send the resume link here. |
| language | string | The language being answered in, when the form has translations. |
| accessToken | string | Required on a password protected form. |

Response data

```
{ "resumeToken": "9f2c…", "resumeUrl": "https://app.dbrij.com/f/job-application?resume=9f2c…", "emailed": true }
```

### Submit answers

POST`/public/forms/:slug/submit`

Answers are keyed by question key. Keys the form does not ask are removed, and the rest are checked by the same rules the page uses (required questions, conditions, formats): a problem answers 400 with every problem in the message. 10 a minute.

|  |  |  |
| --- | --- | --- |
| answers* | object | Answers keyed by question key. Up to 256 KB of JSON. A file question's answer is the url from an upload ticket. |
| clientId | string | Your own id for this submission, up to 64 characters. Sending it again returns the first response instead of a second one, so a retry is safe. |
| elapsedMs | integer | Milliseconds from opening the form to submitting. See Spam protection. |
| website | string | The hidden field. Always send it empty. See Spam protection. |
| captchaToken | string | The human check's token, when the form view carried a captchaSiteKey. |
| verificationToken | string | From verify/confirm, on a form that confirms who is answering. |
| accessToken | string | From unlock, on a password protected form. |
| resumeToken | string | The draft this submission finishes. The draft is removed once the response is in. |
| language | string | The language answered in. Kept only when the form has that translation. |
| offline | boolean | Answers collected with no signal in the mobile app and sent now. Needs a plan with offline collection. |

Body

```
{
  "answers": { "full_name": "Ada Obi", "email": "ada@example.com", "role": "engineer", "cv": "https://…/forms/…/a1b2c3.pdf" },
  "clientId": "6f1d0c2a-attempt-1",
  "elapsedMs": 48210,
  "website": ""
}
```

Response data

```
{
  "responseId": "3c9e…",
  "status": "complete",
  "message": "Thank you. We will be in touch.",
  "paymentUrl": null
}
```

STATUS

|  |  |  |
| --- | --- | --- |
| complete | status | In, and sent on to its rules, notifications and webhooks. |
| awaiting_payment | status | The form charges. Send the person to paymentUrl; the response goes on once paid. |
| awaiting_approval | status | Waiting for somebody at the company to approve it. |
| awaiting_signature | status | Waiting for the person to sign the record of their answers. |

The result may also carry `redirectUrl` (where to send them next) and, on a quiz that shows it, `score` with the score, the maximum, a percentage and an outcome.

### Send a confirmation code

POST`/public/forms/:slug/verify`

On a form that confirms who is answering, so that one person is one response. The code goes by email or by SMS, as the form is set. A code lasts 10 minutes and allows 5 tries, and one address gets at most 5 codes an hour on one form. 5 a minute.

Body

```
{ "identity": "ada@example.com" }
```

Response data

```
{ "channel": "email", "sentTo": "a••@example.com", "expiresIn": 600 }
```

### Confirm the code

POST`/public/forms/:slug/verify/confirm`

Returns a token to send with the answers as verificationToken. It works once, and the person it proves can answer the form once. 10 a minute.

Body

```
{ "identity": "ada@example.com", "code": "481920" }
```

Response data

```
{ "verificationToken": "…" }
```

### Get somewhere to upload a file

POST`/public/forms/:slug/upload`

Nobody needs an account to attach a file. Ask for a ticket for one file question, `PUT` the file to `putUrl` within 15 minutes, then send `url` as that question's answer. The extension must be one the question accepts. When the company's file storage is full the ticket is refused with "This form is not taking files right now." 20 a minute.

Body

```
{ "fieldKey": "cv", "ext": "pdf" }
```

Response data

```
{ "putUrl": "https://…", "fileKey": "forms/…/a1b2c3.pdf", "url": "https://…/forms/…/a1b2c3.pdf" }
```

Upload

```
await fetch(ticket.putUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type || 'application/octet-stream' } });
answers.cv = ticket.url;
```

### Report progress through the form

POST`/public/forms/:slug/track`

Feeds the drop off report: where people stop. Always answers 204, whatever happens. 120 a minute.

|  |  |  |
| --- | --- | --- |
| sessionKey* | string | A random id for this visit, up to 64 characters. Never tied to an account. |
| type* | string | started, field_seen, field_done, submitted or abandoned. |
| fieldKey | string | The question it is about. |
| dwellMs | integer | Milliseconds spent on that question, up to one hour. |

## Spam protection

Every form has two quiet checks, and can add a third.

|  |  |  |
| --- | --- | --- |
| Hidden field | website | The page carries a field people never see. Anything in it means a script filled the form. |
| Timing | elapsedMs | Submitted less than one second after the form opened: nobody read those questions. |
| Human check | captchaToken | Optional, switched on per form. |

A submission caught by the hidden field or the timing check is thanked and dropped: it gets an ordinary looking success with a random response id, and nothing is stored. The script learns nothing it could tune itself against. Answers collected offline skip the timing check.

### The human check

When a form asks for one, the form view carries `captchaSiteKey`. Render the check with that key and send its token as `captchaToken`; a failed check answers 400 "Please finish the check that shows you are a person, then send again." If the checker itself cannot be reached, the answer goes through rather than being lost. The check runs only on Dbrij's own address: on a custom domain the form view carries no key and the hidden field and timing check stand alone.

### Other guards

- Each public route has its own limit of requests a minute from one address (see Public form API).
- A form that confirms who is answering takes one response per proven email address or phone number.
- Dbrij can pause a company's submissions or set a daily ceiling to stop abuse. A submission past the ceiling answers 429, "This form has taken all the answers it can today."

## Webhooks

Send every response to your own system. Add a connection from the form's Connect panel: a webhook (your own https address) or a team chat channel (paste the incoming webhook URL your team chat app gives you for that channel). A form sends to up to 10 places. A webhook address must start with `https://` and be on the public internet.

A delivery goes out when a response is complete. A response held for payment, approval or a signature goes out once it is through, never before. Each webhook gets a signing secret, `whsec_…`, shown once when you create it: keep it then.

### The request

Headers

```
POST /your/endpoint
content-type: application/json
x-dbrij-event: form.response
x-dbrij-signature: sha256=<hex>
x-dbrij-signature-v2: t=<unix seconds>,sha256=<hex>
```

Body

```
{
  "type": "form.response",
  "form": { "id": "f1a2…", "name": "Job application", "slug": "job-application" },
  "response": {
    "id": "3c9e…",
    "submittedAt": "2026-11-03T09:14:22.000Z",
    "answers": { "full_name": "Ada Obi", "role": "engineer" },
    "readable": { "Your name": "Ada Obi", "Role": "Engineer" },
    "score": null,
    "maxScore": null,
    "language": null
  }
}
```

`answers` is keyed by question key, exactly as stored. `readable` is the same answers as people read them, keyed by the question's words, with empty answers left out.

### Verify the signature

Both signatures are an HMAC SHA256 of the raw request body with your secret. Prefer the second: it signs the timestamp with the body, so a captured delivery cannot be replayed later. Compute over the exact bytes you received, before any JSON parsing.

Node

```
import { createHmac, timingSafeEqual } from 'node:crypto';

const same = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));

// x-dbrij-signature-v2: "t=1767430462,sha256=…"
function verifyV2(rawBody, header, secret, toleranceSec = 300) {
  const [tPart, sigPart] = header.split(',');
  const t = tPart.slice(2);
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = 'sha256=' + createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
  return same(sigPart, expected);
}

// x-dbrij-signature: "sha256=…"
function verify(rawBody, header, secret) {
  const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex');
  return same(header, expected);
}
```

### Retries and switching off

- Any answer other than 2xx, or no answer within 10 seconds, is a failure.
- A failed delivery is tried up to 5 times in all, waiting longer each time from 30 seconds.
- Each failed try counts. After 20 failures in a row the connection switches itself off, and the panel shows why. A success sets the count back to zero, and switching the connection on again does too.
- The Connect panel shows each connection's last status and error, and can send a test: a sample response, with id `test`, built from the form's questions.

### Team chat

A team chat connection posts a message with the form's name, up to 12 answers and a link to the response in Dbrij. It is not signed.

### Email

A form can also email up to 10 people at the company for each new response, and send the person who answered a confirmation from the company, with their answers if you choose. Both are set in the form's settings.

## Live spreadsheet feed

Turn on the live spreadsheet link in the form's Connect panel and you get a secret address that always answers with the latest responses as CSV. Spreadsheet apps can import it from the web (with an `IMPORTDATA` formula, or a data import from a web address) and refresh it on their own schedule.

### Read the feed

GET`/public/forms/feed/<token>.csv`

Address`https://api.dbrij.com/api/v1/public/forms/feed/<token>.csv`

- The newest 5,000 responses, oldest first. Unfinished drafts are left out.
- Plain CSV, not the JSON envelope: UTF 8 with a byte order mark, lines ending in CRLF, never cached.
- The secret in the address is the only key. Turning the link on again makes a new address and kills the old one; turning it off makes the address answer 404, "This link has been turned off."
- 30 requests a minute from one address.

### Columns

`Response id`, `Submitted` (ISO 8601, UTC), `Status`, `Respondent` (the confirmed email or phone, when the form confirms who is answering), `Collected offline`, `Arrived late`, then `Score` on a quiz and `Language` when anyone answered in a translation, then one column per question, headed with the question's words. Several answers to one question are joined with `;`.

Sample

```
Response id,Submitted,Status,Respondent,Collected offline,Arrived late,Your name,Role
3c9e…,2026-11-03T09:14:22.000Z,complete,,no,no,Ada Obi,engineer
```

## Exports

From a form's responses in the dashboard you can download:

- **CSV** of every response.
- **Spreadsheet** (.xlsx) of every response.
- **PDF** of a single response: the questions as they were when it was answered, the answers, who answered and when.

The CSV, the .xlsx file and the live spreadsheet feed are written from one table, so their columns always match (see Live spreadsheet feed).

## Custom domains

Serve your forms from your own domain, `https://forms.yourcompany.com/<slug>`, instead of Dbrij's address. Custom domains come with the Starter plan and up. A company can use up to 5, a domain can belong to one company only, and Dbrij's own domains cannot be used.

### Set one up

Add the domain from a form's Share panel, then publish two records at your DNS host. The panel shows the exact values for your domain.

|  |  |  |
| --- | --- | --- |
| TXT | _dbrij-forms.<domain> | Value dbrij-forms=<token>. Proves the domain is yours. |
| CNAME | <domain> | Points the domain at Dbrij: forms.dbrij.com, or the target the panel shows. |

DNS records

```
_dbrij-forms.forms.yourcompany.com.  TXT    "dbrij-forms=4b1e…"
forms.yourcompany.com.               CNAME  forms.dbrij.com.
```

Then choose Verify. The TXT record is checked first, then the CNAME, and the message says which one is missing. Records can take a few minutes to appear. Once verified, the domain serves your company's forms at `/<slug>` and a certificate is issued for it on the first visit; a domain never serves another company's form.

On a custom domain the human check is not offered; the hidden field and the timing check still apply (see Spam protection).

## Plans and limits

Forms is billed per company, monthly or yearly; a year costs ten months. The limits below are the ones the server enforces.

| Plan | Monthly | Responses a month | Live forms | File storage | Rules per form | Payments | Confirm who answers | Offline collection | No Dbrij badge | Custom domain |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Free | Free | 500 | 3 | 100 MB | 1 | No | No | No | No | No |
| Starter | ₦8,000 | 5,000 | 25 | 5 GB | 10 | Yes | Yes | Yes | Yes | Yes |
| Growth | ₦25,000 | 50,000 | 500 | 50 GB | 50 | Yes | Yes | Yes | Yes | Yes |

### How each limit behaves

- **Responses a month** count across every form the company owns, per calendar month (UTC). Drafts saved to finish later never count. Past the allowance a submission answers 400, "This form has stopped taking answers."
- **Live forms** are checked when you publish. Drafts never count.
- **File storage** is measured on the stored files. When it is full, upload tickets are refused and the form stops taking files.
- **Answers collected offline** are never refused, for any limit, window or cap: they already exist. One that arrives after the form closed is kept and marked as arriving late.
- A subscription that lapses keeps its plan through a three day grace period, then drops to Free. Forms keep collecting.

### Fixed limits

|  |  |  |
| --- | --- | --- |
| Answers in one response | 256 KB | Of JSON, after unknown keys are removed. |
| Connections per form | 10 | Webhooks and team chat channels together. |
| People told about each response | 10 | Email addresses in the form's settings. |
| Other languages per form | 8 | Translations besides the form's own language. |
| Custom domains per company | 5 | Starter plan and up. |
| Spreadsheet feed | 5,000 rows | The newest responses. |
| Draft kept | 30 days | From the last save. |
| Access token | 6 hours | From unlocking a password protected form. |
| Confirmation code | 10 minutes, 5 tries | At most 5 codes an hour to one address on one form. |

## SDKs and packages

Every SDK and package for Forms, with what it does, how to install it and the smallest working use. The whole catalogue, for every product, is on [the apps page](https://dbrij.com/apps#sdks).

- JavaScript 2 packages
- Browsers 2 packages

### forms-embed.js

Put a Dbrij form on any website with one script tag, as a button that opens it or inline in the page.

Script for your site · JavaScript · Served by Dbrij · Runs on Browsers

- A button that opens the form over the page in a centred window
- Inline mode puts the form in the page and grows it to fit
- Button words and colour are set on the tag
- The form loads only when somebody asks for it
- No globals and inline styles only; several forms on one page each work on their own

Install

```
<script src="https://app.dbrij.com/forms-embed.js" data-form="job-application" data-label="Apply now" async></script>
```

Quick start

```
<script src="https://app.dbrij.com/forms-embed.js" data-form="job-application" data-mode="inline" data-height="720" async></script>
```

### reach-form.js

A floating contact button for any website that opens a Dbrij form in a panel.

Script for your site · JavaScript · Served by Dbrij · Runs on Browsers

- A round button in the corner opens the hosted form in a panel over the page
- Stacks above the Resolve support bubble when both share a corner
- Steps aside while the support chat is open
- Loads the form only when a visitor opens it; Escape closes it

Install

```
<script src="https://dbrij.com/reach-form.js" data-form="YOUR_FORM_SLUG" data-origin="https://dbrij.com" defer></script>
```

Quick start

```
<!-- before </body> --><script src="https://dbrij.com/reach-form.js" data-form="YOUR_FORM_SLUG" data-origin="https://dbrij.com" defer></script>
```
