> For the complete documentation index, see [llms.txt](https://irisfile.co/llms.txt). Markdown versions of every page are available by appending .md to its URL (the home page is /index.md).

# The IRISfile API

The IRISfile API checks 1099 files and converts them to IRIS XML. It files them with the IRS under IRISfile's transmitter control code (TCC) and reports each receipt and acknowledgement by webhook. The API is in private beta.

OpenAPI 3.1 spec: [https://irisfile.co/openapi.json](https://irisfile.co/openapi.json)

## The beta runs on sandbox keys

During the private beta, every call uses a sandbox key. A sandbox key files with the IRS test environment and never files a live return. To join the beta, email [support@irisfile.co](mailto:support@irisfile.co).

### Test TINs only

Every payer and payee TIN in a sandbox file must start with 000, as IRS Publication 5719 requires for test data. Any other TIN fails with: "Sandbox files accept only IRS test TINs (Pub 5719: first three digits 000). Use a live API key for real taxpayer data."

### No license and no charges

Sandbox files need no license and use no form credits: debitedCents and every quote read 0. balanceCents still shows your account's credit balance.

### XML output only

A sandbox file serves the xml output. The portal-csv and copy-b outputs answer 409 sandbox\_output\_unavailable.

### Separate from live data

A sandbox key sees only sandbox submissions and sandbox webhook endpoints. Any other id answers 404 not\_found.

## Base URL and authentication

**Base URL**: `https://irisfile.co/api/v1`

**API key**: Send it as `Authorization: Bearer <key>` or as an `x-api-key` header. Sandbox keys start with `irf_test_`.

**Rate limit**: 60 requests a minute per key, across all servers. Over it, the API answers 429 with a Retry-After header.

**Request size**: Up to 4.5 MB per request. Upload a larger file, up to 50 MB, through POST /uploads.

**Errors**: Every error has the shape `{"error": {"code", "message", "requestId"}}`. Branch on `code`. The `requestId` appears on operational failures; quote it to support.

## Quick start: from a file to an IRS acknowledgement

These steps file two test 1099-NEC records with the IRS sandbox using curl. Each step uses the values the step before it returned.

1.  ### Create a sandbox key

    In the dashboard, open [API keys](https://irisfile.co/dashboard/settings/api-keys), choose Sandbox and click Create key. Copy the key: IRISfile shows it once. The [API key help article](https://irisfile.co/help/create-an-api-key) shows each screen.

    ```
    export IRISFILE_API_KEY=irf_test_...   # your sandbox key
    export BASE=https://irisfile.co/api/v1
    ```

2.  ### Register a webhook endpoint

    Register an https endpoint before you send a file: IRISfile sends an event only to the endpoints that exist when it happens. Copy the secret from the response: it is not shown again. Send a test event, then check the delivery.

    ```
    curl -s "$BASE/webhook-endpoints" \
      -H "Authorization: Bearer $IRISFILE_API_KEY" \
      -H "content-type: application/json" \
      -d '{"url":"https://example.com/irisfile/webhook","description":"Staging"}'
    ```

    ```
    {
      "endpoint": { "id": "0b7d...", "url": "https://example.com/irisfile/webhook", "environment": "sandbox", ... },
      "secret": "whsec_..."
    }
    ```

    ```
    export ENDPOINT=0b7d...   # endpoint.id
    curl -s -X POST "$BASE/webhook-endpoints/$ENDPOINT/test" -H "Authorization: Bearer $IRISFILE_API_KEY"
    curl -s "$BASE/webhook-endpoints/$ENDPOINT/deliveries" -H "Authorization: Bearer $IRISFILE_API_KEY"
    ```

3.  ### Validate a test file

    Save these two records as sandbox-nec.csv. They use the IRISfile CSV template columns and IRS test TINs. Validating stores nothing.

    ```
    form_type,tax_year,payer_tin,payer_tin_type,payer_name,payer_address,payer_city,payer_state,payer_zip,payer_phone,payee_tin,payee_tin_type,payee_name,payee_address,payee_city,payee_state,payee_zip,account_number,box_1
    NEC,2025,00-0111111,EIN,Harbor Light Bookkeeping LLC,40 Wharf St,Portland,ME,04101,2075550100,000-11-1112,SSN,Dana M Whitfield,18 Spring St,Portland,ME,04102,HL-001,8400.00
    NEC,2025,00-0111111,EIN,Harbor Light Bookkeeping LLC,40 Wharf St,Portland,ME,04101,2075550100,000-11-1113,SSN,Luis A Ferreira,7 Ocean Ave,South Portland,ME,04106,HL-002,12650.00
    ```

    ```
    curl -s "$BASE/validate" \
      -H "Authorization: Bearer $IRISFILE_API_KEY" \
      -F "file=@sandbox-nec.csv"
    ```

    ```
    {
      "validation": {
        "source": "csv",
        "recordCount": 2,
        "counts": { "parseErrors": 0, "validationErrors": 0, "warnings": 0, "infos": 1, "excludedRecords": 0 },
        "readyToConvert": true,
        "diagnostics": [{
          "recordIndex": null, "sourceRow": null, "field": null,
          "severity": "info", "code": "cfsf.auto_elected",
          "message": "CF/SF elected from the payee's state for 2 records (ME 2)."
        }],
        "diagnosticsTruncated": false
      }
    }
    ```

4.  ### Create a submission

    The same file, sent to /submissions, is stored and converted to IRIS XML in the same request. The answer is 201 with the submission. A file over 4.5 MB goes through /uploads first, and that submission converts in the background (202).

    ```
    curl -s "$BASE/submissions" \
      -H "Authorization: Bearer $IRISFILE_API_KEY" \
      -F "file=@sandbox-nec.csv"
    ```

    ```
    {
      "submission": {
        "id": "6f1c2a9e-...",
        "status": "xml_generated",
        "stage": "ready_to_file",
        "counts": { "records": 2, "validationErrors": 0, "lockedForms": 0, ... },
        "transmissions": [{ "sequence": 1, "taxYear": 2025, "records": 2, "status": "not_filed", ... }],
        ...
      }
    }
    ```

    ```
    # Files over 4.5 MB: reserve, PUT, then create from the token.
    curl -s "$BASE/uploads" -H "Authorization: Bearer $IRISFILE_API_KEY" \
      -H "content-type: application/json" \
      -d '{"filename":"big.csv","size":31457280,"contentType":"text/csv"}'
    curl -X PUT "<uploadUrl>" -H "content-type: text/csv" --data-binary @big.csv
    curl -s "$BASE/submissions" -H "Authorization: Bearer $IRISFILE_API_KEY" \
      -H "content-type: application/json" \
      -d '{"uploadToken":"<uploadToken>","filename":"big.csv"}'
    ```

5.  ### Check the status

    Poll the submission until its stage is ready\_to\_file or ready\_to\_unlock, or wait for the submission.converted webhook. Then file it. ready\_to\_unlock means some forms are not paid for yet. The file call pays for them from your credits, and it needs the tax year's license. If the stage reads needs\_fixing, list the errors, fix the file and create a new submission.

    ```
    export ID=6f1c2a9e-...   # submission.id
    curl -s "$BASE/submissions/$ID" -H "Authorization: Bearer $IRISFILE_API_KEY"
    curl -s "$BASE/submissions/$ID/diagnostics?severity=error" -H "Authorization: Bearer $IRISFILE_API_KEY"
    ```

6.  ### File with the IRS

    The file call sends each transmission to the IRS sandbox and returns its receipt ID. A sandbox file needs no body. If the answer is 409 filing\_incomplete, send the same call again later: a transmission is never sent twice.

    ```
    curl -s -X POST "$BASE/submissions/$ID/file" \
      -H "Authorization: Bearer $IRISFILE_API_KEY"
    ```

    ```
    {
      "status": "submitted",
      "transmissions": [{
        "sequence": 1,
        "status": "receipted",
        "utid": "c55915bb-...:IRIS:...::A",
        "receiptId": "2025-...",
        "state": "submitted",
        ...
      }],
      "billing": { "debitedCents": 0, "balanceCents": 0 }
    }
    ```

7.  ### Download the XML

    The outputs route answers 302 to a storage link that expires in 5 minutes. Its JSON body repeats the link with the billing.

    ```
    # Without -L, curl prints the JSON body: the link, its expiry and the billing.
    curl -s "$BASE/submissions/$ID/outputs/xml" -H "Authorization: Bearer $IRISFILE_API_KEY"
    # With -L, curl follows the link and saves the file.
    curl -sL "$BASE/submissions/$ID/outputs/xml" -H "Authorization: Bearer $IRISFILE_API_KEY" -o irs.xml
    ```

8.  ### Wait for the acknowledgement

    The IRS sandbox acknowledges a transmission after it gives the receipt. IRISfile asks the IRS for acknowledgements at most every 60 minutes, and less often after an IRS lookup error. Your endpoint then gets transmission.acknowledged with the ack code. Without a webhook, poll the submission until each transmission has an ackCode.

## Endpoints

Every path is relative to `https://irisfile.co/api/v1`. The [OpenAPI spec](https://irisfile.co/openapi.json) has each request body, response and error code.

| Request | What it does |
| --- | --- |
| `POST /validate` | Check a file or JSON records. Stores nothing. |
| `POST /uploads` | Reserve a direct upload for a file over 4.5 MB. |
| `POST /submissions` | Create a submission from a file or an upload, and convert it. |
| `GET /submissions/{id}` | Status, counts, the quote for unpaid forms and each transmission. |
| `GET /submissions/{id}/diagnostics` | Errors, warnings and notices, one page at a time. |
| `POST /submissions/{id}/file` | File with the IRS. Safe to retry. |
| `GET /submissions/{id}/outputs/{kind}` | Redirect to the xml, portal-csv or copy-b download. |
| `GET /webhook-endpoints` | List your webhook endpoints. |
| `POST /webhook-endpoints` | Register an endpoint. Returns its signing secret once. |
| `DELETE /webhook-endpoints/{id}` | Delete an endpoint and cancel its pending deliveries. |
| `POST /webhook-endpoints/{id}/test` | Send a webhook.test event to one endpoint. |
| `GET /webhook-endpoints/{id}/deliveries` | The endpoint's recent deliveries and their results. |

**`POST /validate`**: Check a file or JSON records. Stores nothing.

**`POST /uploads`**: Reserve a direct upload for a file over 4.5 MB.

**`POST /submissions`**: Create a submission from a file or an upload, and convert it.

**`GET /submissions/{id}`**: Status, counts, the quote for unpaid forms and each transmission.

**`GET /submissions/{id}/diagnostics`**: Errors, warnings and notices, one page at a time.

**`POST /submissions/{id}/file`**: File with the IRS. Safe to retry.

**`GET /submissions/{id}/outputs/{kind}`**: Redirect to the xml, portal-csv or copy-b download.

**`GET /webhook-endpoints`**: List your webhook endpoints.

**`POST /webhook-endpoints`**: Register an endpoint. Returns its signing secret once.

**`DELETE /webhook-endpoints/{id}`**: Delete an endpoint and cancel its pending deliveries.

**`POST /webhook-endpoints/{id}/test`**: Send a webhook.test event to one endpoint.

**`GET /webhook-endpoints/{id}/deliveries`**: The endpoint's recent deliveries and their results.

## Common error codes

Only the file call and the outputs route spend form credits. A form stays paid after its first debit, so an error after the debit costs nothing to retry. The spec lists every code per route.

**`invalid_api_key` 401**: Send a current key as Authorization: Bearer or x-api-key.

**`rate_limited` 429**: Wait for the seconds in Retry-After, then retry.

**`invalid_request` 400**: Fix the body or the query. The message names the problem.

**`not_found` 404**: The id is not in this account, or it belongs to the other environment.

**`not_ready` 409**: The file is still converting. Retry after Retry-After.

**`filing_incomplete` 409**: Some transmissions may already be filed and paid. Keep the submission, read transmissions and billing in the body, then retry the same call. Nothing is sent twice.

**`transmission_required` 409**: Add ?transmission=<sequence> to download one transmission's XML.

**`sandbox_output_unavailable` 409**: Sandbox files serve only the xml output.

**`license_required` 402**: Buy the tax year's license in the dashboard.

**`payment_required` 402**: Buy form credits. The body carries the quote and the balance.

**`irs_filing_failed` 502**: The IRS refused or failed the call. Retry later; the body lists each transmission.

## Credits and billing headers

Live filing needs the tax year's license and prepaid form credits. The file call debits credits for each unpaid form before any transmission exists. A form paid once, in the dashboard or through the API, costs nothing again. See [pricing](https://irisfile.co/pricing) for the rates.

With too few credits, the call answers 402 `payment_required` with the quote and the balance. Successful file calls return a `billing` object. The outputs redirect carries the same numbers as headers: `IRISfile-Debited-Cents`, `IRISfile-Balance-Cents` and, when known, `IRISfile-Excluded-Records`. A client that follows the redirect loses those headers, so read them with redirects off.

## Webhooks

IRISfile posts each event as JSON to your https endpoints in the key's environment. An account can hold 5 endpoints per environment. Payloads carry ids, statuses, counts and IRS receipt IDs, never a TIN, a name or an amount.

`submission.converted`

A conversion finished, once per submission: ready\_to\_file, needs\_fixing, split or failed.

`transmission.receipted`

The IRS gave a transmission its receipt ID.

`transmission.acknowledged`

The IRS acknowledgement arrived: ackCode A, E, P or R.

`transmission.failed`

The IRS refused the transmission, or 72 hours passed with no acknowledgement. Checks continue after it.

`webhook.test`

You asked for a test event for one endpoint.

### An event

```
{
  "id": "5d0e...",
  "type": "transmission.acknowledged",
  "created": "2026-10-21T18:04:11.000Z",
  "apiVersion": "2026-10-21",
  "environment": "sandbox",
  "data": {
    "submission": { "id": "6f1c2a9e-...", "parentId": null },
    "transmission": { "id": "...", "sequence": 1, "taxYear": 2025, "receiptId": "2025-...",
                      "ackCode": "A", "irsStatus": "Accepted", "errorCount": 0 }
  }
}
```

### Delivery

-   Each post carries `IRISfile-Signature`, `IRISfile-Event-Id` and `IRISfile-Event-Type` headers.
-   Answer with any 2xx status within 10 seconds. A redirect or any other status counts as a failure.
-   A failed post is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. The delivery stops after 7 attempts.
-   Delivery is at least once, so skip an event `id` you have already handled.

### Verify the signature

The header reads `t=<unix seconds>,v1=<hex>`. The v1 value is an HMAC-SHA256 of `${t}.${raw body}`, keyed with the endpoint's whole `whsec_` secret. Compute it over the raw body before you parse it, compare in constant time, and reject a `t` more than 300 seconds from your clock.

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

// rawBody: the request body exactly as received, before JSON.parse.
// header: the IRISfile-Signature header. secret: your whsec_... string.
export function verifyIrisfileSignature(rawBody, header, secret, toleranceSeconds = 300) {
  if (!header) return false;
  let t = null;
  const v1 = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i < 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === "t" && /^\d{1,12}$/.test(value)) t = Number(value);
    if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) v1.push(value);
  }
  if (t === null || v1.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
  const expected = Buffer.from(
    createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest("hex"),
    "ascii",
  );
  return v1.some((s) => timingSafeEqual(Buffer.from(s, "ascii"), expected));
}
```

In Express, read the body raw on the webhook route only:

```
import express from "express";
import { verifyIrisfileSignature } from "./verify.js";

const app = express();
app.post("/irisfile/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const body = req.body.toString("utf8");
  if (!verifyIrisfileSignature(body, req.get("IRISfile-Signature"), process.env.IRISFILE_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(body);
  // Delivery is at least once: skip an event.id you have already handled.
  res.sendStatus(204);
});
```

## Questions

**Can the API file real 1099s during the beta?**: The beta runs on sandbox keys. A sandbox key files only with the IRS test environment, marked as test data, and accepts only IRS test TINs that start with 000.

**Is there an OpenAPI spec for the IRISfile API?**: Yes. The OpenAPI 3.1 spec is at https://irisfile.co/openapi.json and needs no key. It lists every route, every error code each route returns and the webhook payloads.

**How do I know when the IRS accepts a filing?**: Register a webhook endpoint and handle transmission.acknowledged; ackCode A means accepted. You can also poll GET /api/v1/submissions/{id} and read each transmission's status.

**Is it safe to call the file endpoint twice?**: Yes. IRISfile writes a transmission's UTID before it calls the IRS and never resubmits a transmission that has one. A repeat call looks up the receipt instead, and a form already paid for costs nothing again.

File your first test 1099 with the IRS sandbox.

[Get notified](https://irisfile.co/sign-up)
