curl --request POST \
--url https://api.numeralhq.com/tax/certificates \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--header 'X-API-Version: <x-api-version>' \
--data '
{
"document_url": "https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...",
"file_name": "resale.pdf",
"customer": {
"reference_customer_id": "20506"
}
}
'import requests
url = "https://api.numeralhq.com/tax/certificates"
payload = {
"document_url": "https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...",
"file_name": "resale.pdf",
"customer": { "reference_customer_id": "20506" }
}
headers = {
"X-API-Version": "<x-api-version>",
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {
'X-API-Version': '<x-api-version>',
Authorization: 'Bearer <token>',
'Content-Type': 'application/json'
},
body: JSON.stringify({
document_url: 'https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...',
file_name: 'resale.pdf',
customer: {reference_customer_id: '20506'}
})
};
fetch('https://api.numeralhq.com/tax/certificates', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.numeralhq.com/tax/certificates",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'document_url' => 'https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...',
'file_name' => 'resale.pdf',
'customer' => [
'reference_customer_id' => '20506'
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json",
"X-API-Version: <x-api-version>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.numeralhq.com/tax/certificates"
payload := strings.NewReader("{\n \"document_url\": \"https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...\",\n \"file_name\": \"resale.pdf\",\n \"customer\": {\n \"reference_customer_id\": \"20506\"\n }\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Version", "<x-api-version>")
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.numeralhq.com/tax/certificates")
.header("X-API-Version", "<x-api-version>")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"document_url\": \"https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...\",\n \"file_name\": \"resale.pdf\",\n \"customer\": {\n \"reference_customer_id\": \"20506\"\n }\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.numeralhq.com/tax/certificates")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Version"] = '<x-api-version>'
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"document_url\": \"https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...\",\n \"file_name\": \"resale.pdf\",\n \"customer\": {\n \"reference_customer_id\": \"20506\"\n }\n}"
response = http.request(request)
puts response.read_body{
"id": "upl_4471",
"object": "tax.certificate_upload",
"status": "processing",
"customer": {
"id": "cust_6126acaf-7379-411a-8ada-00005bac0715",
"reference_customer_id": "20506"
},
"certificate_ids": [],
"error_message": null,
"created_at": "2026-09-15T17:04:11.000Z",
"livemode": true
}{
"code": 400,
"type": "MISSING_FIELD",
"message": "Required field 'address_country' is missing"
}{
"code": 404,
"type": "CUSTOMER_NOT_FOUND",
"message": "Customer not found"
}{
"code": 400,
"type": "MISSING_FIELD",
"message": "Required field 'address_country' is missing"
}{
"code": 400,
"type": "MISSING_FIELD",
"message": "Required field 'address_country' is missing"
}{
"code": 400,
"type": "MISSING_FIELD",
"message": "Required field 'address_country' is missing"
}Submit Certificate
Submit a certificate document for asynchronous processing
curl --request POST \
--url https://api.numeralhq.com/tax/certificates \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--header 'X-API-Version: <x-api-version>' \
--data '
{
"document_url": "https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...",
"file_name": "resale.pdf",
"customer": {
"reference_customer_id": "20506"
}
}
'import requests
url = "https://api.numeralhq.com/tax/certificates"
payload = {
"document_url": "https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...",
"file_name": "resale.pdf",
"customer": { "reference_customer_id": "20506" }
}
headers = {
"X-API-Version": "<x-api-version>",
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {
'X-API-Version': '<x-api-version>',
Authorization: 'Bearer <token>',
'Content-Type': 'application/json'
},
body: JSON.stringify({
document_url: 'https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...',
file_name: 'resale.pdf',
customer: {reference_customer_id: '20506'}
})
};
fetch('https://api.numeralhq.com/tax/certificates', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.numeralhq.com/tax/certificates",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'document_url' => 'https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...',
'file_name' => 'resale.pdf',
'customer' => [
'reference_customer_id' => '20506'
]
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json",
"X-API-Version: <x-api-version>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.numeralhq.com/tax/certificates"
payload := strings.NewReader("{\n \"document_url\": \"https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...\",\n \"file_name\": \"resale.pdf\",\n \"customer\": {\n \"reference_customer_id\": \"20506\"\n }\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Version", "<x-api-version>")
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.numeralhq.com/tax/certificates")
.header("X-API-Version", "<x-api-version>")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"document_url\": \"https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...\",\n \"file_name\": \"resale.pdf\",\n \"customer\": {\n \"reference_customer_id\": \"20506\"\n }\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.numeralhq.com/tax/certificates")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Version"] = '<x-api-version>'
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"document_url\": \"https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=...\",\n \"file_name\": \"resale.pdf\",\n \"customer\": {\n \"reference_customer_id\": \"20506\"\n }\n}"
response = http.request(request)
puts response.read_body{
"id": "upl_4471",
"object": "tax.certificate_upload",
"status": "processing",
"customer": {
"id": "cust_6126acaf-7379-411a-8ada-00005bac0715",
"reference_customer_id": "20506"
},
"certificate_ids": [],
"error_message": null,
"created_at": "2026-09-15T17:04:11.000Z",
"livemode": true
}{
"code": 400,
"type": "MISSING_FIELD",
"message": "Required field 'address_country' is missing"
}{
"code": 404,
"type": "CUSTOMER_NOT_FOUND",
"message": "Customer not found"
}{
"code": 400,
"type": "MISSING_FIELD",
"message": "Required field 'address_country' is missing"
}{
"code": 400,
"type": "MISSING_FIELD",
"message": "Required field 'address_country' is missing"
}{
"code": 400,
"type": "MISSING_FIELD",
"message": "Required field 'address_country' is missing"
}https URL to the document (typically a presigned URL to your own
storage); Numeral downloads it, stores it, and runs the classification
pipeline. The certificate type and jurisdiction are determined from the
document itself — there is no certificate_type_id input.
The response is a tax.certificate_upload, a processing job. Poll
GET /tax/certificate-uploads/{upload_id}
until its status leaves processing, then read certificate_ids to find
the resulting certificates.
X-API-Version: 2026-03-01 header — older versions
return INVALID_REQUEST (400). Certificate endpoints are live-only — using a
sk_test_* key returns TESTMODE_NOT_SUPPORTED (400).The document_url field
document_url is fetched server-side, so it must be reachable by Numeral’s
servers without any session or cookie:
httpsonly. Redirects are followed up to 3 hops, and every hop must also behttps.- PDF, PNG, or JPEG. The type is detected from the file content, not the
extension or
Content-Typeheader. - 10 MB max, checked against both
Content-Lengthand the downloaded bytes.
UNSUPPORTED_FILE_TYPE. Use a
direct download link or a presigned URL instead.
Pre-mapping to a customer
Passcustomer to attribute the certificate to a buyer up front. Either
identifier alone is sufficient:
customer.id— an existing Numeral customer id. An unknown id returnsCUSTOMER_NOT_FOUND(404).customer.reference_customer_id— your own customer identifier. An unknown value creates the customer, so you can pre-map a certificate before the customer exists in Numeral.
BUYER_IDENTITY_CONFLICT (409) and nothing is written.
reference_customer_id, and once the resulting certificate is active, any
POST /tax/calculations carrying the same reference_customer_id applies
the exemption automatically. No further wiring is needed.customer is valid. The upload processes unattributed, Numeral
matches it to a buyer during processing, and the response customer is
null.
Duplicate submissions
Resubmitting a byte-identical document with the samefile_name returns
201 with status: "duplicate" rather than reprocessing the document. The
duplicate upload’s certificate_ids stays empty — use your original upload
id to retrieve its certificates. When the original upload had failed,
error_message on the duplicate carries the same reason so you can see why
resubmitting the same bytes will not help.
Response
201 Created with the tax.certificate_upload object. status is
processing or duplicate on create, and certificate_ids is always empty
at this point.
Errors
| HTTP | Type | When |
|---|---|---|
| 400 | INVALID_DOCUMENT_URL | Not a valid URL, not https, blocked or unresolvable host, or private address. |
| 400 | DOCUMENT_FETCH_FAILED | Download failed — network error, too many redirects, HTTP error from the document host, or empty file. |
| 422 | DOCUMENT_URL_UNAUTHORIZED | The document host returned 401 or 403, most commonly an expired presigned URL. Mint a fresh one and retry. |
| 400 | UNSUPPORTED_FILE_TYPE | Content is not PDF, PNG, or JPEG. HTML gets a targeted message about share/viewer links. |
| 400 | FILE_TOO_LARGE | Over 10 MB. |
| 404 | CUSTOMER_NOT_FOUND | customer.id does not exist for this account. |
| 409 | BUYER_IDENTITY_CONFLICT | customer.id and customer.reference_customer_id resolve to different customers. Nothing was written. |
| 400 | TESTMODE_NOT_SUPPORTED | sk_test_* key. |
| 400 | INVALID_REQUEST | API version below 2026-03-01, or a schema violation (missing document_url, empty customer object, etc.). |
| 500 | NUMERAL_ERROR | The upload was stored but processing could not start. Retry the submission. |
Authorizations
Bearer authentication header of the form Bearer <token>, where <token> is your auth token.
Headers
Must be 2026-03-01 or later. Older versions return INVALID_REQUEST (400).
2026-03-01 Body
An https URL that Numeral's servers can GET — typically a presigned URL to your own storage. Fetched server-side. The file must be a PDF, PNG, or JPEG (detected from content, not extension or Content-Type), at most 10 MB, reachable within 3 redirects (each hop must also be https).
"https://your-bucket.s3.amazonaws.com/certs/resale.pdf?X-Amz-Signature=..."
Display name for the document (1–255 characters). Defaults to the URL's basename when it has an extension, otherwise to a generic name derived from the detected file type.
1 - 255"resale.pdf"
Pre-maps the resulting certificate to a customer. Supply at least one of id or reference_customer_id. Either alone is sufficient; if both are present and resolve to different customers the request fails with BUYER_IDENTITY_CONFLICT (409) and nothing is written.
Show child attributes
Show child attributes
Response
Upload accepted. status is processing or duplicate; certificate_ids is always empty at this point — poll GET /tax/certificate-uploads/{upload_id} for results.
A certificate upload — the asynchronous processing job created by POST /tax/certificates.
Upload id, upl_<number>. Endpoints that accept an upload id also accept the bare numeric form.
"upl_4471"
The type of object: tax.certificate_upload
"tax.certificate_upload"
Processing state of a certificate upload. This is a separate vocabulary from certificate status — an upload is a processing job, a certificate is its output. processing is the only non-terminal state; completed (certificates produced), failed (see error_message), and duplicate (same document already submitted) are terminal.
processing, completed, failed, duplicate The customer supplied at submission. null when the upload was submitted without a customer — attribution then happens during processing.
Show child attributes
Show child attributes
Certificates produced by this upload, usable with the certificate endpoints. Empty while status is processing; populated at completed. One document can yield multiple certificates.
Human-readable reason processing failed. Also set on duplicate uploads when the original upload had failed, so you can see why re-submitting the same bytes will not help.
Always true — these endpoints are live-only.
true