# Filling a form

To fill a form, send a `POST` request to:

```
https://philforms.com/api/v1/engine/fill/{form_id}
```

The body is a JSON object with your answers, keyed by parameter name. The answer is the filled PDF (`application/pdf`).

## Send values the way you already have them

You don't need to format anything for the paper. PhilForms does that for you:

| You send | The form gets |
|---|---|
| `2026-01-15`, `01/15/2026` or `January 15, 2026` | The date in the form's own boxes, in the order that form uses |
| `123-456-789-000` | One digit per TIN box, with the branch code when the form has room for it |
| `₱12,500.00` or `12500` | Pesos and centavos, lined up at the printed decimal point |
| `+63 917 123 4567` | The mobile number spread across its boxes |

If a value can't fit on the paper, you get an error that names the limit. PhilForms never cuts a value short without telling you.

## Options

These are all optional.

| Option | Where it goes | What it does |
|---|---|---|
| `style` | In the body | Ink colour, letter case and size. Example: `{ "ink": "blue", "case": "upper" }`. Ink can be `blue`, `black` or a colour like `#1A3D7C`. Size is 5 to 24. |
| `profile_id` | In the body | Fills any blank boxes from a saved profile, such as your TIN and address. |
| `option` | In the body | Switches the automatic help on or off: arithmetic, rounding, capital letters, copies, saved details, how names are read, and fields left blank. See [Switch the automatic help](#switch-the-automatic-help). |
| `?require=a,b` | In the address | Refuses to fill unless these parameters are present. |
| `?strict=1` | In the address | Refuses keys the form doesn't know, instead of ignoring them. |
| `?revise=<document_id>` | In the address | Edits a PDF you already made: same form, within 7 days, same filer. Free. See [Credits](/docs/credits). |

## What comes back

Besides the PDF, the response has headers your code can read:

| Header | Meaning |
|---|---|
| `X-Document-Id` | The id of this document. The verify page reports the same id. |
| `X-Fields-Filled` | How many of your parameters were used. |
| `X-Annex-Count` | How many long answers went to annex pages at the end of the PDF. Only some forms allow this; their fields say `is_annex: true`. |
| `X-Layout` | `adjusted` when table rows were resized. You asked for it with `"layout": { "row": "fit" }`. Only forms whose agency publishes an editable original allow it. Other forms answer `layout_not_allowed`. The PDF says at the foot of the page that it is not the published proportions. |
| `X-Boxes-Filled` | How many printed boxes received ink (a TIN fills one box per digit). |
| `X-Unknown-Keys` | Keys you sent that this form doesn't have. They were ignored. Only sent when there are some. A key that looks like a typo of a real one is refused instead — see below. |
| `X-Core-Fields` | The core fields `X-Is-Complete` is judged on: the fields the form marks required, or else the filer's ID, name and address. |
| `X-Fields-Missing` | Core fields you left blank, such as the filer's TIN. Only sent when one is blank. |
| `X-Is-Complete` | `true` when every **core** field is filled. It does not mean every box is filled: page totals, payment details, dates and signatures can still be blank. |
| `X-Missing-Count` | How many of the form's fields are blank, core or not, including signatures, photos and empty tables. Tick boxes left unticked don't count. |
| `X-Fields-Blank` | The first 20 of those blank fields, by name. A check (`/engine/check`) lists them all under `missing`. |
| `X-Credit-Cost` | Credits this call used: `1` for a new PDF, `0` for an edit or a test key. |
| `X-Credit-Remaining` | Only sent when the PDF was paid with a prepaid credit: how many prepaid credits are left. |
| `X-Revise-Of`, `X-Revise-Remaining` | On an edit: the first document, and how many free edits it has left. |
| `X-Quota-Used`, `X-Quota-Limit` | Credits used this month, and your plan's credits. |
| `X-Copy-Count` | How many copies of the form this PDF holds: `1`, or more when your rows ran past what a table prints (see below). |

## More rows than the form prints

A table prints a fixed number of rows: the SSS R-3 holds 15 members a page, the PhilHealth RF-1 10 employees. On these two forms, send **all** your rows in one fill. You get one PDF with as many copies of the form as the rows need, filled the way the paper is filed:

- the rows in the order you sent them, 15 (or 10) to a copy;
- the employer's details on every copy;
- `PAGE 1 OF 3`, `PAGE 2 OF 3`… on the R-3;
- each copy's page totals added up from that copy's rows ("TOTALS FOR THIS PAGE (to be filled out on every page)", the RF-1's SUBTOTAL), and the RF-1's employees per page;
- the grand total and payment details where the form says: on the R-3 the last page only ("Fill out the Grand Total and Payment Details of the last page of the R-3 only", its Instructions 7), on the RF-1 every page ("GRAND TOTAL … To be accomplished on every page").

Leave `page_number`, `page_count` and the page totals out: each copy has its own, so a value sent once is refused (`per_copy_value`). The whole PDF is one document (one `X-Document-Id`, one seal) and costs **one credit**, however many copies it holds. `X-Copy-Count` says how many; the free `check` answers `copy_count`. On every other form, more rows than it prints is refused (`too_many_rows`) — PhilForms does not guess how a form continues. The R-3's contribution cells print whole pesos: send `1350`, not `1350.00`.

The PNG pictures in one request come to at most 8 megapixels, and in one batch to at most 24, with a picture used many times counted once. A picture repeated on every copy of a long fill has its own size limit, and the refusal says it. Both refuse with `bad_image`.

A test key, a trial or an unpaid plan holds at most 200 pages in one PDF, a refusal with `too_many_rows`. Long unpaid PDFs draw two at a time, and a `429` means the line is full, so try again in a minute. A paid key prints as many copies as the form numbers.

Sources: [SSS R-3](https://www.sss.gov.ph/wp-content/uploads/2022/03/SSSForms_Contribution_Collection_List.pdf), [PhilHealth RF-1](https://www.philhealth.gov.ph/downloads/employer/rf1.pdf).

## Let the form do its own arithmetic

Add `?compute=1` to `/api/v1/engine/check/{form_id}` or `/api/v1/engine/fill/{form_id}` and the lines the form itself says how to work out are filled for you — only where you left them blank. For example, on BIR 1701Q at the 8% rate, sending the quarter's sales fills Items 49, 51, 53 and 54 (8% of Item 53) and carries the tax due to Part III. Each rule is the printed instruction on the form ("Sum of Items 47 and 48", "Item 53 x 8% Tax Rate"); the graduated income tax comes from the tax table printed on 1701Q itself (Table 1 for 2018–2022, Table 2 from 2023, by the return's year); a line the form does not spell out stays yours. The `check` response lists what was computed under `computed`; a fill names the lines in the `X-Computed` header. Limits the form prints come back under `warning` (`{ field, message, source }`) — for example the 8% rate on 1701Q is only for up to ₱3,000,000 — and so does a total you typed that the form's own arithmetic contradicts (`is_mismatch: true`, e.g. 1601-EQ Item 19 against its rows); nothing is changed for them. Warnings come back with or without `?compute=1`: in the check's `warning`, and as the field names in a fill's `X-Formula-Warning` header. On 1701Q, computed lines are rounded to the peso, as the form instructs ("49 centavos or less drop down; 50 or more round up"), and the ₱250,000 reduction (Item 52) is filled unless the ATC is II016 (mixed income). Covered today: BIR 1700, 1701, 1701A, 1701Q, 1601-C, 1601-EQ, 2316, 2550Q, 2551Q and 0605.

```bash
curl -X POST "https://philforms.com/api/v1/engine/check/bir-1701q?compute=1" -H "Content-Type: application/json" \
  -d '{ "tax_rate_option": "eight_percent", "eight_percent_sales_revenue_receipt_fee_taxpayer": "650000" }'
# → "computed": { "eight_percent_tax_due_taxpayer": "52000.00", "tax_due_taxpayer": "52000.00", … }
```

Or send `"option": { "is_compute": true }` in the body instead of `?compute=1`. `"is_compute": false` turns it off, even when your account turned it on.

## Switch the automatic help

Every fill gets some help by default: the form works out its totals, capital letters on forms that ask for them, a box the form repeats copies the first one, and saved details fill blanks with titles dropped from names. Each of these can be turned off or tuned with the `option` body key. It works on `check`, `fill`, `batch` (one `option` for every row) and profile `apply`.

| Key | Values | What it does |
|---|---|---|
| `is_compute` | `true` / `false` | The form's own arithmetic. Off by default in the API, on in the app. |
| `rounding` | `form` / `centavo` | `form` drops centavos where the form says so. `centavo` always keeps two decimals. |
| `is_warning` | `true` / `false` | Warnings for limits the form prints. |
| `case` | `form` / `upper` / `as_is` | Capital letters only where the form asks, always, or never. `style.case` still wins. |
| `is_copy` | `true` / `false` | A box the form repeats (the TIN on page 2) copies the first one. |
| `is_tick_choice` | `true` / `false` | Saved details tick sex, civil status and citizenship. |
| `is_split_address` | `true` / `false` | A saved one-line address fills street, barangay, city and ZIP boxes. |
| `is_split_name` | `true` / `false` | A saved `LAST, FIRST MIDDLE` fills last, first and middle name boxes, and saved parts fill a one-box name. |
| `is_org_detail` | `true` / `false` | A workflow run inside an organization fills the employer or payor boxes a row left blank from the organization's saved details. Send it in the run body's `option`. |
| `is_client_party` | `true` / `false` | Certificates issued for a client (`POST /api/v1/clients/:id/certificates`) take that client's TIN, name and address as the payor or employer unless you send them. |
| `name_title` | `saved` / `all` / `keep` | Titles (Ms., Mr., Atty.) are dropped from saved names, also from names you send, or kept. |
| `name_title_word` | list of words | More words that count as a title, such as `["Kgg.", "Capt."]`. |
| `name_particle` | `join` / `split` | `join` keeps "Dela Cruz" as one surname. |
| `name_particle_word` | list of words | More surname particles. |
| `name_order` | `last_first` / `first_last` | How a one-box name is written from saved parts: `DELA CRUZ, JUAN SANTOS` or `Juan Santos Dela Cruz`. |
| `field` | `{ "<name>": { … } }` | Per field: `is_off` (never filled, even if sent), `is_compute`, `is_autofill` (saved details), `is_copy`. |

```bash
curl -X POST "https://philforms.com/api/v1/engine/fill/bir-1701q" -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "tax_rate_option": "eight_percent", "eight_percent_sales_revenue_receipt_fee_taxpayer": "650000",
        "option": { "is_compute": true, "rounding": "centavo", "field": { "spouse_tin": { "is_off": true } } } }'
```

Unknown keys and values are refused with a `bad_option` issue, never ignored. `GET /api/v1/fill-option` lists every option with its choices, default and a plain description.

The same switches are on the **Filling** tab of your account (a default for every form) and under **Form settings** on each form's fill page (one form, field by field). A request made with your account's key follows them. Later wins: the default, then your account, then the form's setting, then the request's `option`.

## When something goes wrong

Every refusal is JSON with the same two keys on top: `error`, a sentence for a person, and `code`, a fixed word for your code. Branch on `code`; the sentence may be reworded, a code is never renamed. Most also carry a `hint` with the next step. A refused value adds the `issue` list, which tells you exactly what to fix:

```json
{
  "error": "Invalid values for this form",
  "code": "invalid_value",
  "issue": [{
    "field": "payee_tin",
    "label": "Taxpayer Identification Number (TIN)",
    "code": "has_letter",
    "message": "\"12A-456-789\" has letters — expected a 9-digit TIN, optionally followed by a 3–5 digit branch code (NNN-NNN-NNN-NNNNN)",
    "expected": "a 9-digit TIN, optionally followed by a 3–5 digit branch code (NNN-NNN-NNN-NNNNN)"
  }],
  "hint": "GET /api/v1/parameters/bir-2307"
}
```

| Status | `code` | What it means |
|---|---|---|
| 400 | `invalid_value` | A value doesn't fit the form. The `issue` list says which (each issue has its own `code`, such as `has_letter`). |
| 400 | `bad_request`, `bad_json`, `row_limit_exceeded`, `bad_date_window` | The request itself is wrong: no parameters, a body that isn't JSON, more batch rows than your plan takes, a deadline window that isn't real dates or runs past 3,700 days. |
| 401 | `key_required`, `invalid_key` | The key is missing, or unknown / revoked. |
| 402 | `quota_exceeded` | This month's credits can't pay for it. A batch says `credit_needed` and `credit_left`. |
| 402 | `test_key_limit` | A test key's 500 PDFs this month are used. |
| 403 | `test_key_not_allowed` | Your organization's data policy does not allow test keys or Practice. |
| 402 | `plan_required` | A later form of a pack needs a paid plan, the trial or a prepaid credit. |
| 403 | `forbidden`, `email_unverified`, `test_key_values`, `test_key_write`, `session_required`, `password_required`, `two_factor_required_by_org` | This key, role or policy may not do it. A test key changes nothing saved. |
| 404 | `form_not_found` | There is no form with that id — the same answer from fill, check and batch. Search `GET /api/v1/engine/forms?q=…`. |
| 404 | `job_not_found`, `not_found`, `endpoint_not_found` | No such job / thing / API route. |
| 409 | `conflict` | A `?revise` that isn't an edit of the same form and filer, or a job that is still running. Nothing is charged. |
| 410 | `gone` | A deleted job's ZIP, or an expired link. |
| 422 | `invalid_value` | From `POST /api/v1/engine/check/{form_id}` only: `is_valid: false` with the same `issue` list. A valid check is `200`. |
| 422 | `idempotency_conflict` | The `Idempotency-Key` was already used with a different body. |
| 429 | `rate_limited`, `daily_form_limit`, `login_wait`, `login_busy` | Too many requests for now, too many different forms today on an unpaid tier, or too many wrong passwords on this account. |
| 429 | `too_many_jobs`, `job_storage_full` | One account runs 20 batches at a time and keeps 10 GB of finished ZIPs. Wait for a batch to finish, or download and delete older ones. |
| 500 / 503 | `internal_error`, `unavailable` | Our side — retry. |
| 503 | `document_busy`, `document_too_slow` | A generated document is waiting too long, or took too long to draw. It says `Retry-After`. Try again, or send less text. |

`GET /api/v1/openapi.json` lists every code in the `Error` schema.

> **Tip:** run a free check first (`POST /api/v1/engine/check/{form_id}`, same body, no key). It returns the same `issue` list without making a PDF.

## Rate limits

Every limited endpoint sends `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds) on each answer, and `Retry-After` with a `429`. Limits are a fixed window **per endpoint, per client address** (an IPv6 address counts as its whole /64): two keys on one server share them, and one key used from two servers has two. They are not per key or per account.

| Endpoint | Limit |
|---|---|
| `POST /api/v1/engine/fill/{id}` | 300 a minute |
| `POST /api/v1/engine/check/{id}` | 120 a minute |
| `POST /api/v1/engine/batch/{id}` | 30 a minute (each up to your plan's `batch_row_max` rows and 2 GB of PDFs; rows past 2 GB are refused in the job and cost nothing) |
| `GET /api/v1/engine/jobs/{id}` | 240 a minute |
| `GET /api/v1/engine/jobs`, `GET …/jobs/{id}/errors` | 120 a minute |
| `GET …/jobs/{id}/download`, `DELETE /api/v1/engine/jobs/{id}` | 60 a minute |
| `GET /api/v1/parameters/{id}`, `GET /api/v1/engine/forms` | 120 a minute |
| `POST /api/v1/documents/{id}`, its `/batch`, `POST /api/v1/documents/custom` | 20 a minute, batches 5 a minute, and 500 documents a day per network |
| `POST /api/v1/verify` | 60 a minute |
| `POST /api/v1/engine/scan/{id}`, `/engine/ingest/{id}`, `/engine/extract` | 10 a minute |
| `POST /api/v1/recipes/{id}/run`, `/workflows/{id}/run`, `/schedules` | 30 a minute |
| `POST /api/v1/webhooks` | 30 a minute; `…/{id}/test` 10 a minute |

Reading a form's field list (`GET /api/v1/parameters/:id`) counts as opening that form; send your key so it counts against your account. An account whose email is not confirmed has the same budget as a visitor without one. Unpaid tiers (no key, test keys, Free and the trial) also have a budget of **different forms a day**, counted per address and per account: 12 without an account, 30 for a test key, 40 on Free or the trial, 60 for everyone behind one address. Past it a `429` with `code: "daily_form_limit"` says when it resets. Paid plans have no form budget.

Example dates and amounts in a field list can differ by a digit between keys and addresses. Each is valid for its field; treat them as samples, not fixed test data.

## Idempotency-Key

Send an `Idempotency-Key` header (any unique string up to 200 characters — your own id for the request) on `POST /api/v1/engine/fill/{id}` or `POST /api/v1/engine/batch/{id}`, and a retry can never make a second document or a second charge:

- The same key with the same body within 24 hours replays the first answer, with `Idempotent-Replayed: true`: the same PDF bytes and the same `X-Document-Id` for a fill, the same job for a batch.
- Two requests with the same key that arrive at the same moment: the second waits for the first to finish, then replays it.
- The same key with a different body is refused with `422` and `code: "idempotency_conflict"`. Use a new key for a new request.

```bash
curl -X POST https://philforms.com/api/v1/engine/fill/bir-2307 -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: payroll-2026-03-emp-1042" \
  -d '{ "payee_tin": "123-456-789-000", "payee_name": "DELA CRUZ, JUAN" }' -o 2307.pdf
```

## Retrying a fill

A retry after a timeout does not charge you twice. Sending the **same values** for the same form again in the same
month is a replay: it costs nothing, and the response says which document it repeats in `X-Replay-Of` (the new copy has
its own `X-Document-Id`). A key inside a table row that the form does not have is listed in `X-Unknown-Keys` as
`table[0].key`; add `?strict=1` to have the call refused instead.

A key that is almost a real name — `employees` for the R-3's `member` table, `ss_numbers` for `ss_number`, `payeeTin`
for `payee_tin`, a one- or two-letter typo — is refused with a 400 (and `is_valid: false` from a check). Its issue has
`code: "unknown_parameter"` and `did_you_mean` with the name to use. Rename it and retry, or send
`"ignore_unknown_column": true` in the body (or `?ignore_unknown_column=1`) to leave unknown keys out and fill the rest.

