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
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.
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."
Sandbox files need no license and use no form credits: debitedCents and every quote read 0. balanceCents still shows your account's credit balance.
A sandbox file serves the xml output. The portal-csv and copy-b outputs answer 409 sandbox_output_unavailable.
A sandbox key sees only sandbox submissions and sandbox webhook endpoints. Any other id answers 404 not_found.
https://irisfile.co/api/v1Authorization: Bearer <key> or as an x-api-key header. Sandbox keys start with irf_test_.{"error": {"code", "message", "requestId"}}. Branch on code. The requestId appears on operational failures; quote it to support.These steps file two test 1099-NEC records with the IRS sandbox using curl. Each step uses the values the step before it returned.
In the dashboard, open API keys, choose Sandbox and click Create key. Copy the key: IRISfile shows it once. The API key help article shows each screen.
export IRISFILE_API_KEY=irf_test_... # your sandbox key
export BASE=https://irisfile.co/api/v1Register 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"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
}
}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"}'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"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 }
}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.xmlThe 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.
Every path is relative to https://irisfile.co/api/v1. The OpenAPI spec has each request body, response and error code.
POST /validatePOST /uploadsPOST /submissionsGET /submissions/{id}GET /submissions/{id}/diagnosticsPOST /submissions/{id}/fileGET /submissions/{id}/outputs/{kind}GET /webhook-endpointsPOST /webhook-endpointsDELETE /webhook-endpoints/{id}POST /webhook-endpoints/{id}/testGET /webhook-endpoints/{id}/deliveriesOnly 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 401rate_limited 429invalid_request 400not_found 404not_ready 409filing_incomplete 409transmission_required 409sandbox_output_unavailable 409license_required 402payment_required 402irs_filing_failed 502Live 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 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.
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.
{
"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 }
}
}IRISfile-Signature, IRISfile-Event-Id and IRISfile-Event-Type headers.id you have already handled.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);
});File your first test 1099 with the IRS sandbox.
Get notified