# Step 1: request presigned upload URLs
curl --request POST \
--url https://api.sandbox.goteal.co/payroll/entries/payslips/presign \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{
"files": [
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"}
]
}'
# Step 2: upload PDFs with the provided presigned URLs
curl --request PUT \
--url "<presigned_url_from_step_1>" \
--header 'Content-Type: application/octet-stream' \
--data-binary '@./fixtures/payslip1.pdf'
# Step 3: submit uploaded payslips for processing
curl --request POST \
--url https://api.sandbox.goteal.co/payroll/entries/payslips \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{
"files": [
{"path": "client/acct123/users/user456/payslip1.pdf", "external_document_id": "ext-jan-2024"},
{"path": "client/acct123/users/user456/payslip2.pdf"}
]
}'
import json
import requests
token = "<token>"
base_url = "https://api.sandbox.goteal.co"
headers = {"Authorization": f"Bearer {token}"}
# Step 1: request presigned URLs
presign_payload = {
"files": [
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"},
]
}
presign_res = requests.post(
f"{base_url}/payroll/entries/payslips/presign",
headers={**headers, "Content-Type": "application/json"},
json=presign_payload,
)
presign_res.raise_for_status()
presigned_urls = presign_res.json()["presigned_urls"]
# Step 2: upload PDFs via HTTP PUT
for url_info, file_name in zip(presigned_urls, ["payslip1.pdf", "payslip2.pdf"]):
with open(f"./fixtures/{file_name}", "rb") as f:
put_res = requests.put(
url_info["presigned_url"],
data=f,
headers={"Content-Type": "application/octet-stream"},
)
put_res.raise_for_status()
# Step 3: submit uploaded file paths
submit_payload = {
"files": [
{
"path": url_info["path"],
"external_document_id": url_info.get("external_id"),
}
for url_info in presigned_urls
]
}
submit_res = requests.post(
f"{base_url}/payroll/entries/payslips",
headers={**headers, "Content-Type": "application/json"},
json=submit_payload,
)
submit_res.raise_for_status()
print(json.dumps(submit_res.json(), indent=2))
import axios from 'axios'
const token = '<token>'
const baseURL = 'https://api.sandbox.goteal.co'
// Step 1: request presigned URLs
const presignResponse = await axios.post(
`${baseURL}/payroll/entries/payslips/presign`,
{
files: [
{ file_name: 'payslip1.pdf', external_id: 'ext-jan-2024' },
{ file_name: 'payslip2.pdf' }
]
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
const presignedUrls = presignResponse.data.presigned_urls
// Step 2: upload PDFs
await Promise.all(
presignedUrls.map(async (urlInfo, index) => {
const fileData = await fetch(`/fixtures/payslip${index + 1}.pdf`).then(res => res.blob())
await axios.put(urlInfo.presigned_url, fileData, {
headers: {'Content-Type': 'application/octet-stream'}
})
})
)
// Step 3: submit uploaded file paths
const submitResponse = await axios.post(
`${baseURL}/payroll/entries/payslips`,
{
files: presignedUrls.map(urlInfo => ({
path: urlInfo.path,
external_document_id: urlInfo.external_id ?? undefined
}))
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
console.log(submitResponse.data)
<?php
$token = '<token>';
$baseUrl = 'https://api.sandbox.goteal.co';
function postJson(string $url, array $payload, string $token): array {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json'
],
]);
$response = curl_exec($curl);
if ($response === false) {
throw new RuntimeException('Request failed: ' . curl_error($curl));
}
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($status >= 400) {
throw new RuntimeException('Request returned status ' . $status . ': ' . $response);
}
return json_decode($response, true);
}
// Step 1: request presigned URLs
$presignPayload = [
'files' => [
['file_name' => 'payslip1.pdf', 'external_id' => 'ext-jan-2024'],
['file_name' => 'payslip2.pdf'],
],
];
$presignResponse = postJson($baseUrl . '/payroll/entries/payslips/presign', $presignPayload, $token);
// Step 2: upload PDFs via PUT
foreach ($presignResponse['presigned_urls'] as $index => $urlInfo) {
$filePath = __DIR__ . '/fixtures/payslip' . ($index + 1) . '.pdf';
$fileHandle = fopen($filePath, 'rb');
$putCurl = curl_init($urlInfo['presigned_url']);
curl_setopt_array($putCurl, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_UPLOAD => true,
CURLOPT_INFILE => $fileHandle,
CURLOPT_INFILESIZE => filesize($filePath),
CURLOPT_HTTPHEADER => ['Content-Type: application/octet-stream'],
]);
$putResponse = curl_exec($putCurl);
if ($putResponse === false) {
throw new RuntimeException('Upload failed: ' . curl_error($putCurl));
}
$status = curl_getinfo($putCurl, CURLINFO_HTTP_CODE);
curl_close($putCurl);
fclose($fileHandle);
if ($status >= 400) {
throw new RuntimeException('Upload returned status ' . $status . ': ' . $putResponse);
}
}
// Step 3: submit uploaded file paths
$submitPayload = [
'files' => array_map(function ($urlInfo) {
return array_filter([
'path' => $urlInfo['path'],
'external_document_id' => $urlInfo['external_id'] ?? null,
]);
}, $presignResponse['presigned_urls'])
];
$submitResponse = postJson($baseUrl . '/payroll/entries/payslips', $submitPayload, $token);
print_r($submitResponse);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
func main() {
token := "<token>"
baseURL := "https://api.sandbox.goteal.co"
client := &http.Client{}
presignPayload := map[string]any{
"files": []map[string]any{
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"},
},
}
presignBody, _ := json.Marshal(presignPayload)
presignReq, _ := http.NewRequest("POST", baseURL+"/payroll/entries/payslips/presign", bytes.NewBuffer(presignBody))
presignReq.Header.Set("Authorization", "Bearer "+token)
presignReq.Header.Set("Content-Type", "application/json")
presignRes, err := client.Do(presignReq)
if err != nil {
panic(err)
}
defer presignRes.Body.Close()
if presignRes.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(presignRes.Body)
panic(fmt.Sprintf("presign failed: %s", string(bodyBytes)))
}
var presignData struct {
PresignedURLs []struct {
FileName string `json:"file_name"`
ExternalID *string `json:"external_id"`
PresignedURL string `json:"presigned_url"`
Path string `json:"path"`
} `json:"presigned_urls"`
}
if err := json.NewDecoder(presignRes.Body).Decode(&presignData); err != nil {
panic(err)
}
for i, urlInfo := range presignData.PresignedURLs {
file, err := os.Open(fmt.Sprintf("./fixtures/payslip%d.pdf", i+1))
if err != nil {
panic(err)
}
defer file.Close()
putReq, _ := http.NewRequest("PUT", urlInfo.PresignedURL, file)
putReq.Header.Set("Content-Type", "application/octet-stream")
putRes, err := client.Do(putReq)
if err != nil {
panic(err)
}
putRes.Body.Close()
if putRes.StatusCode >= 400 {
panic(fmt.Sprintf("upload failed with status %d", putRes.StatusCode))
}
}
submitPayload := map[string]any{
"files": func() []map[string]any {
files := make([]map[string]any, len(presignData.PresignedURLs))
for i, urlInfo := range presignData.PresignedURLs {
entry := map[string]any{"path": urlInfo.Path}
if urlInfo.ExternalID != nil {
entry["external_document_id"] = *urlInfo.ExternalID
}
files[i] = entry
}
return files
}(),
}
submitBody, _ := json.Marshal(submitPayload)
submitReq, _ := http.NewRequest("POST", baseURL+"/payroll/entries/payslips", bytes.NewBuffer(submitBody))
submitReq.Header.Set("Authorization", "Bearer "+token)
submitReq.Header.Set("Content-Type", "application/json")
submitRes, err := client.Do(submitReq)
if err != nil {
panic(err)
}
defer submitRes.Body.Close()
bodyBytes, _ := io.ReadAll(submitRes.Body)
fmt.Println(submitRes.Status)
fmt.Println(string(bodyBytes))
}
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import kong.unirest.HttpResponse;
import kong.unirest.Unirest;
String token = "<token>";
String baseUrl = "https://api.sandbox.goteal.co";
ObjectMapper mapper = new ObjectMapper();
HttpResponse<String> presignResponse = Unirest.post(baseUrl + "/payroll/entries/payslips/presign")
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.body("{\n \"files\": [\n {\"file_name\": \"payslip1.pdf\", \"external_id\": \"ext-jan-2024\"},\n {\"file_name\": \"payslip2.pdf\"}\n ]\n}")
.asString();
if (!presignResponse.isSuccess()) {
throw new RuntimeException("Presign request failed: " + presignResponse.getBody());
}
JsonNode presignJson = mapper.readTree(presignResponse.getBody());
for (int i = 0; i < presignJson.get("presigned_urls").size(); i++) {
JsonNode urlInfo = presignJson.get("presigned_urls").get(i);
byte[] pdfBytes = java.nio.file.Files.readAllBytes(java.nio.file.Path.of("./fixtures/payslip" + (i + 1) + ".pdf"));
HttpResponse<String> putResponse = Unirest.put(urlInfo.get("presigned_url").asText())
.header("Content-Type", "application/octet-stream")
.body(pdfBytes)
.asString();
if (!putResponse.isSuccess()) {
throw new RuntimeException("Upload failed: " + putResponse.getBody());
}
}
HttpResponse<String> submitResponse = Unirest.post(baseUrl + "/payroll/entries/payslips")
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.body(presignJson.toString().replace("presigned_urls", "files"))
.asString();
if (!submitResponse.isSuccess()) {
throw new RuntimeException("Submit failed: " + submitResponse.getBody());
}
System.out.println(submitResponse.getBody());
{
"account_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"entry_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"payroll_submissions": [
{
"id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"account_id": "674744df-9626-47ef-ae2b-4a491be136b5",
"entry_id": "be770ba4-1362-46cd-8c1c-2330ce3a8b69",
"created_at": "2019-05-17T00:00:00.000Z",
"document_external_id": "payslip123456",
"document_filename": "file1-payslip.pdf",
"source": "Payslip (via Doc Scan)",
"identity_information": {
"name": "John Smith",
"date_of_birth": "2019-05-17T00:00:00.000Z",
"address": {
"street": "123 Main Street",
"county": "Greater London",
"city": "London",
"post_code": "SW1A 1AA",
"country": "United Kingdom"
},
"email": "john.smith@company.com",
"phone": 447123456789,
"NI_number": "AB123456C"
},
"employment_information": {
"employer_name": "Acme Ltd",
"role": "Software Engineer",
"type": "Full-time",
"status": "Active",
"start_date": "2019-05-17T00:00:00.000Z",
"leave_date": "2019-05-17T00:00:00.000Z"
},
"income_information": {
"pay_date": "2023-05-27T00:00:00.000Z",
"pay_interval_start": "2023-05-01T00:00:00.000Z",
"pay_interval_end": "2023-05-31T00:00:00.000Z",
"pay_frequency": "Monthly",
"earnings": {
"gross_pay": 3500,
"net_pay": 2500,
"base_salary": 3000,
"bonus": 500
},
"deductions": {
"income_tax": 500,
"employee_ni": 200,
"employee_pension": 300,
"total_deductions": 1000
}
}
},
"trust_score": "High"
],
"payslip_errors" : [{
"error" : "File is not a payslip",
"file_name" : "Payslip3.pdf"
}]
}
Payroll
Payslips upload
Uploading payslips is a three-step flow:
- Request upload slots with
POST /payroll/entries/payslips/presign, providing each PDF file name (and optional external identifier). The response includespresigned_urlandpathvalues for every requested file. - Upload the PDF content to every
presigned_urlusing an HTTPPUTrequest withContent-Type: application/octet-stream. Each file must be a searchable, machine-readable PDF (scanned images are not supported). - Call this endpoint with the JSON payload returned in step one,
echoing the
path(and optionalexternal_document_id) for every uploaded file so Teal can process the documents. Thepathvalues you submit must match the ones returned by the presign step. Any files that failed validation or upload are reported in thepayslip_errorsarray.
POST
/
payroll
/
entries
/
payslips
# Step 1: request presigned upload URLs
curl --request POST \
--url https://api.sandbox.goteal.co/payroll/entries/payslips/presign \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{
"files": [
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"}
]
}'
# Step 2: upload PDFs with the provided presigned URLs
curl --request PUT \
--url "<presigned_url_from_step_1>" \
--header 'Content-Type: application/octet-stream' \
--data-binary '@./fixtures/payslip1.pdf'
# Step 3: submit uploaded payslips for processing
curl --request POST \
--url https://api.sandbox.goteal.co/payroll/entries/payslips \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{
"files": [
{"path": "client/acct123/users/user456/payslip1.pdf", "external_document_id": "ext-jan-2024"},
{"path": "client/acct123/users/user456/payslip2.pdf"}
]
}'
import json
import requests
token = "<token>"
base_url = "https://api.sandbox.goteal.co"
headers = {"Authorization": f"Bearer {token}"}
# Step 1: request presigned URLs
presign_payload = {
"files": [
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"},
]
}
presign_res = requests.post(
f"{base_url}/payroll/entries/payslips/presign",
headers={**headers, "Content-Type": "application/json"},
json=presign_payload,
)
presign_res.raise_for_status()
presigned_urls = presign_res.json()["presigned_urls"]
# Step 2: upload PDFs via HTTP PUT
for url_info, file_name in zip(presigned_urls, ["payslip1.pdf", "payslip2.pdf"]):
with open(f"./fixtures/{file_name}", "rb") as f:
put_res = requests.put(
url_info["presigned_url"],
data=f,
headers={"Content-Type": "application/octet-stream"},
)
put_res.raise_for_status()
# Step 3: submit uploaded file paths
submit_payload = {
"files": [
{
"path": url_info["path"],
"external_document_id": url_info.get("external_id"),
}
for url_info in presigned_urls
]
}
submit_res = requests.post(
f"{base_url}/payroll/entries/payslips",
headers={**headers, "Content-Type": "application/json"},
json=submit_payload,
)
submit_res.raise_for_status()
print(json.dumps(submit_res.json(), indent=2))
import axios from 'axios'
const token = '<token>'
const baseURL = 'https://api.sandbox.goteal.co'
// Step 1: request presigned URLs
const presignResponse = await axios.post(
`${baseURL}/payroll/entries/payslips/presign`,
{
files: [
{ file_name: 'payslip1.pdf', external_id: 'ext-jan-2024' },
{ file_name: 'payslip2.pdf' }
]
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
const presignedUrls = presignResponse.data.presigned_urls
// Step 2: upload PDFs
await Promise.all(
presignedUrls.map(async (urlInfo, index) => {
const fileData = await fetch(`/fixtures/payslip${index + 1}.pdf`).then(res => res.blob())
await axios.put(urlInfo.presigned_url, fileData, {
headers: {'Content-Type': 'application/octet-stream'}
})
})
)
// Step 3: submit uploaded file paths
const submitResponse = await axios.post(
`${baseURL}/payroll/entries/payslips`,
{
files: presignedUrls.map(urlInfo => ({
path: urlInfo.path,
external_document_id: urlInfo.external_id ?? undefined
}))
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
console.log(submitResponse.data)
<?php
$token = '<token>';
$baseUrl = 'https://api.sandbox.goteal.co';
function postJson(string $url, array $payload, string $token): array {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json'
],
]);
$response = curl_exec($curl);
if ($response === false) {
throw new RuntimeException('Request failed: ' . curl_error($curl));
}
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($status >= 400) {
throw new RuntimeException('Request returned status ' . $status . ': ' . $response);
}
return json_decode($response, true);
}
// Step 1: request presigned URLs
$presignPayload = [
'files' => [
['file_name' => 'payslip1.pdf', 'external_id' => 'ext-jan-2024'],
['file_name' => 'payslip2.pdf'],
],
];
$presignResponse = postJson($baseUrl . '/payroll/entries/payslips/presign', $presignPayload, $token);
// Step 2: upload PDFs via PUT
foreach ($presignResponse['presigned_urls'] as $index => $urlInfo) {
$filePath = __DIR__ . '/fixtures/payslip' . ($index + 1) . '.pdf';
$fileHandle = fopen($filePath, 'rb');
$putCurl = curl_init($urlInfo['presigned_url']);
curl_setopt_array($putCurl, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_UPLOAD => true,
CURLOPT_INFILE => $fileHandle,
CURLOPT_INFILESIZE => filesize($filePath),
CURLOPT_HTTPHEADER => ['Content-Type: application/octet-stream'],
]);
$putResponse = curl_exec($putCurl);
if ($putResponse === false) {
throw new RuntimeException('Upload failed: ' . curl_error($putCurl));
}
$status = curl_getinfo($putCurl, CURLINFO_HTTP_CODE);
curl_close($putCurl);
fclose($fileHandle);
if ($status >= 400) {
throw new RuntimeException('Upload returned status ' . $status . ': ' . $putResponse);
}
}
// Step 3: submit uploaded file paths
$submitPayload = [
'files' => array_map(function ($urlInfo) {
return array_filter([
'path' => $urlInfo['path'],
'external_document_id' => $urlInfo['external_id'] ?? null,
]);
}, $presignResponse['presigned_urls'])
];
$submitResponse = postJson($baseUrl . '/payroll/entries/payslips', $submitPayload, $token);
print_r($submitResponse);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
func main() {
token := "<token>"
baseURL := "https://api.sandbox.goteal.co"
client := &http.Client{}
presignPayload := map[string]any{
"files": []map[string]any{
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"},
},
}
presignBody, _ := json.Marshal(presignPayload)
presignReq, _ := http.NewRequest("POST", baseURL+"/payroll/entries/payslips/presign", bytes.NewBuffer(presignBody))
presignReq.Header.Set("Authorization", "Bearer "+token)
presignReq.Header.Set("Content-Type", "application/json")
presignRes, err := client.Do(presignReq)
if err != nil {
panic(err)
}
defer presignRes.Body.Close()
if presignRes.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(presignRes.Body)
panic(fmt.Sprintf("presign failed: %s", string(bodyBytes)))
}
var presignData struct {
PresignedURLs []struct {
FileName string `json:"file_name"`
ExternalID *string `json:"external_id"`
PresignedURL string `json:"presigned_url"`
Path string `json:"path"`
} `json:"presigned_urls"`
}
if err := json.NewDecoder(presignRes.Body).Decode(&presignData); err != nil {
panic(err)
}
for i, urlInfo := range presignData.PresignedURLs {
file, err := os.Open(fmt.Sprintf("./fixtures/payslip%d.pdf", i+1))
if err != nil {
panic(err)
}
defer file.Close()
putReq, _ := http.NewRequest("PUT", urlInfo.PresignedURL, file)
putReq.Header.Set("Content-Type", "application/octet-stream")
putRes, err := client.Do(putReq)
if err != nil {
panic(err)
}
putRes.Body.Close()
if putRes.StatusCode >= 400 {
panic(fmt.Sprintf("upload failed with status %d", putRes.StatusCode))
}
}
submitPayload := map[string]any{
"files": func() []map[string]any {
files := make([]map[string]any, len(presignData.PresignedURLs))
for i, urlInfo := range presignData.PresignedURLs {
entry := map[string]any{"path": urlInfo.Path}
if urlInfo.ExternalID != nil {
entry["external_document_id"] = *urlInfo.ExternalID
}
files[i] = entry
}
return files
}(),
}
submitBody, _ := json.Marshal(submitPayload)
submitReq, _ := http.NewRequest("POST", baseURL+"/payroll/entries/payslips", bytes.NewBuffer(submitBody))
submitReq.Header.Set("Authorization", "Bearer "+token)
submitReq.Header.Set("Content-Type", "application/json")
submitRes, err := client.Do(submitReq)
if err != nil {
panic(err)
}
defer submitRes.Body.Close()
bodyBytes, _ := io.ReadAll(submitRes.Body)
fmt.Println(submitRes.Status)
fmt.Println(string(bodyBytes))
}
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import kong.unirest.HttpResponse;
import kong.unirest.Unirest;
String token = "<token>";
String baseUrl = "https://api.sandbox.goteal.co";
ObjectMapper mapper = new ObjectMapper();
HttpResponse<String> presignResponse = Unirest.post(baseUrl + "/payroll/entries/payslips/presign")
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.body("{\n \"files\": [\n {\"file_name\": \"payslip1.pdf\", \"external_id\": \"ext-jan-2024\"},\n {\"file_name\": \"payslip2.pdf\"}\n ]\n}")
.asString();
if (!presignResponse.isSuccess()) {
throw new RuntimeException("Presign request failed: " + presignResponse.getBody());
}
JsonNode presignJson = mapper.readTree(presignResponse.getBody());
for (int i = 0; i < presignJson.get("presigned_urls").size(); i++) {
JsonNode urlInfo = presignJson.get("presigned_urls").get(i);
byte[] pdfBytes = java.nio.file.Files.readAllBytes(java.nio.file.Path.of("./fixtures/payslip" + (i + 1) + ".pdf"));
HttpResponse<String> putResponse = Unirest.put(urlInfo.get("presigned_url").asText())
.header("Content-Type", "application/octet-stream")
.body(pdfBytes)
.asString();
if (!putResponse.isSuccess()) {
throw new RuntimeException("Upload failed: " + putResponse.getBody());
}
}
HttpResponse<String> submitResponse = Unirest.post(baseUrl + "/payroll/entries/payslips")
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.body(presignJson.toString().replace("presigned_urls", "files"))
.asString();
if (!submitResponse.isSuccess()) {
throw new RuntimeException("Submit failed: " + submitResponse.getBody());
}
System.out.println(submitResponse.getBody());
{
"account_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"entry_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"payroll_submissions": [
{
"id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"account_id": "674744df-9626-47ef-ae2b-4a491be136b5",
"entry_id": "be770ba4-1362-46cd-8c1c-2330ce3a8b69",
"created_at": "2019-05-17T00:00:00.000Z",
"document_external_id": "payslip123456",
"document_filename": "file1-payslip.pdf",
"source": "Payslip (via Doc Scan)",
"identity_information": {
"name": "John Smith",
"date_of_birth": "2019-05-17T00:00:00.000Z",
"address": {
"street": "123 Main Street",
"county": "Greater London",
"city": "London",
"post_code": "SW1A 1AA",
"country": "United Kingdom"
},
"email": "john.smith@company.com",
"phone": 447123456789,
"NI_number": "AB123456C"
},
"employment_information": {
"employer_name": "Acme Ltd",
"role": "Software Engineer",
"type": "Full-time",
"status": "Active",
"start_date": "2019-05-17T00:00:00.000Z",
"leave_date": "2019-05-17T00:00:00.000Z"
},
"income_information": {
"pay_date": "2023-05-27T00:00:00.000Z",
"pay_interval_start": "2023-05-01T00:00:00.000Z",
"pay_interval_end": "2023-05-31T00:00:00.000Z",
"pay_frequency": "Monthly",
"earnings": {
"gross_pay": 3500,
"net_pay": 2500,
"base_salary": 3000,
"bonus": 500
},
"deductions": {
"income_tax": 500,
"employee_ni": 200,
"employee_pension": 300,
"total_deductions": 1000
}
}
},
"trust_score": "High"
],
"payslip_errors" : [{
"error" : "File is not a payslip",
"file_name" : "Payslip3.pdf"
}]
}
This endpoint requires a valid authorisation for the user. If no active authorisation exists, the request will be rejected.
You can optionally pass the
x-teal-authorisation-id header to specify which authorisation to use; otherwise the system will resolve a valid authorisation automatically.
See Authorisations for more details.End-to-end upload flow
Follow these steps to upload payslips programmatically:- Request upload slots – call
POST /payroll/entries/payslips/presignwith the list of filenames (and optional external identifiers) you intend to upload. The response returnspresigned_urlandpathfields for each file. - Upload PDFs – for every returned
presigned_url, perform an HTTPPUTwithContent-Type: application/octet-stream, streaming the PDF bytes. Files must be searchable, machine-readable PDFs (scanned images are not supported). - Submit metadata – call
POST /payroll/entries/payslips(this endpoint) with a JSON payload that includes thepathfor each uploaded file and an optionalexternal_document_id. Teal uses this metadata to process the uploaded documents. Any files that fail validation are listed inpayslip_errorsin the response.
# Step 1: request presigned upload URLs
curl --request POST \
--url https://api.sandbox.goteal.co/payroll/entries/payslips/presign \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{
"files": [
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"}
]
}'
# Step 2: upload PDFs with the provided presigned URLs
curl --request PUT \
--url "<presigned_url_from_step_1>" \
--header 'Content-Type: application/octet-stream' \
--data-binary '@./fixtures/payslip1.pdf'
# Step 3: submit uploaded payslips for processing
curl --request POST \
--url https://api.sandbox.goteal.co/payroll/entries/payslips \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{
"files": [
{"path": "client/acct123/users/user456/payslip1.pdf", "external_document_id": "ext-jan-2024"},
{"path": "client/acct123/users/user456/payslip2.pdf"}
]
}'
import json
import requests
token = "<token>"
base_url = "https://api.sandbox.goteal.co"
headers = {"Authorization": f"Bearer {token}"}
# Step 1: request presigned URLs
presign_payload = {
"files": [
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"},
]
}
presign_res = requests.post(
f"{base_url}/payroll/entries/payslips/presign",
headers={**headers, "Content-Type": "application/json"},
json=presign_payload,
)
presign_res.raise_for_status()
presigned_urls = presign_res.json()["presigned_urls"]
# Step 2: upload PDFs via HTTP PUT
for url_info, file_name in zip(presigned_urls, ["payslip1.pdf", "payslip2.pdf"]):
with open(f"./fixtures/{file_name}", "rb") as f:
put_res = requests.put(
url_info["presigned_url"],
data=f,
headers={"Content-Type": "application/octet-stream"},
)
put_res.raise_for_status()
# Step 3: submit uploaded file paths
submit_payload = {
"files": [
{
"path": url_info["path"],
"external_document_id": url_info.get("external_id"),
}
for url_info in presigned_urls
]
}
submit_res = requests.post(
f"{base_url}/payroll/entries/payslips",
headers={**headers, "Content-Type": "application/json"},
json=submit_payload,
)
submit_res.raise_for_status()
print(json.dumps(submit_res.json(), indent=2))
import axios from 'axios'
const token = '<token>'
const baseURL = 'https://api.sandbox.goteal.co'
// Step 1: request presigned URLs
const presignResponse = await axios.post(
`${baseURL}/payroll/entries/payslips/presign`,
{
files: [
{ file_name: 'payslip1.pdf', external_id: 'ext-jan-2024' },
{ file_name: 'payslip2.pdf' }
]
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
const presignedUrls = presignResponse.data.presigned_urls
// Step 2: upload PDFs
await Promise.all(
presignedUrls.map(async (urlInfo, index) => {
const fileData = await fetch(`/fixtures/payslip${index + 1}.pdf`).then(res => res.blob())
await axios.put(urlInfo.presigned_url, fileData, {
headers: {'Content-Type': 'application/octet-stream'}
})
})
)
// Step 3: submit uploaded file paths
const submitResponse = await axios.post(
`${baseURL}/payroll/entries/payslips`,
{
files: presignedUrls.map(urlInfo => ({
path: urlInfo.path,
external_document_id: urlInfo.external_id ?? undefined
}))
},
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
)
console.log(submitResponse.data)
<?php
$token = '<token>';
$baseUrl = 'https://api.sandbox.goteal.co';
function postJson(string $url, array $payload, string $token): array {
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json'
],
]);
$response = curl_exec($curl);
if ($response === false) {
throw new RuntimeException('Request failed: ' . curl_error($curl));
}
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($status >= 400) {
throw new RuntimeException('Request returned status ' . $status . ': ' . $response);
}
return json_decode($response, true);
}
// Step 1: request presigned URLs
$presignPayload = [
'files' => [
['file_name' => 'payslip1.pdf', 'external_id' => 'ext-jan-2024'],
['file_name' => 'payslip2.pdf'],
],
];
$presignResponse = postJson($baseUrl . '/payroll/entries/payslips/presign', $presignPayload, $token);
// Step 2: upload PDFs via PUT
foreach ($presignResponse['presigned_urls'] as $index => $urlInfo) {
$filePath = __DIR__ . '/fixtures/payslip' . ($index + 1) . '.pdf';
$fileHandle = fopen($filePath, 'rb');
$putCurl = curl_init($urlInfo['presigned_url']);
curl_setopt_array($putCurl, [
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_UPLOAD => true,
CURLOPT_INFILE => $fileHandle,
CURLOPT_INFILESIZE => filesize($filePath),
CURLOPT_HTTPHEADER => ['Content-Type: application/octet-stream'],
]);
$putResponse = curl_exec($putCurl);
if ($putResponse === false) {
throw new RuntimeException('Upload failed: ' . curl_error($putCurl));
}
$status = curl_getinfo($putCurl, CURLINFO_HTTP_CODE);
curl_close($putCurl);
fclose($fileHandle);
if ($status >= 400) {
throw new RuntimeException('Upload returned status ' . $status . ': ' . $putResponse);
}
}
// Step 3: submit uploaded file paths
$submitPayload = [
'files' => array_map(function ($urlInfo) {
return array_filter([
'path' => $urlInfo['path'],
'external_document_id' => $urlInfo['external_id'] ?? null,
]);
}, $presignResponse['presigned_urls'])
];
$submitResponse = postJson($baseUrl . '/payroll/entries/payslips', $submitPayload, $token);
print_r($submitResponse);
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
func main() {
token := "<token>"
baseURL := "https://api.sandbox.goteal.co"
client := &http.Client{}
presignPayload := map[string]any{
"files": []map[string]any{
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"},
},
}
presignBody, _ := json.Marshal(presignPayload)
presignReq, _ := http.NewRequest("POST", baseURL+"/payroll/entries/payslips/presign", bytes.NewBuffer(presignBody))
presignReq.Header.Set("Authorization", "Bearer "+token)
presignReq.Header.Set("Content-Type", "application/json")
presignRes, err := client.Do(presignReq)
if err != nil {
panic(err)
}
defer presignRes.Body.Close()
if presignRes.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(presignRes.Body)
panic(fmt.Sprintf("presign failed: %s", string(bodyBytes)))
}
var presignData struct {
PresignedURLs []struct {
FileName string `json:"file_name"`
ExternalID *string `json:"external_id"`
PresignedURL string `json:"presigned_url"`
Path string `json:"path"`
} `json:"presigned_urls"`
}
if err := json.NewDecoder(presignRes.Body).Decode(&presignData); err != nil {
panic(err)
}
for i, urlInfo := range presignData.PresignedURLs {
file, err := os.Open(fmt.Sprintf("./fixtures/payslip%d.pdf", i+1))
if err != nil {
panic(err)
}
defer file.Close()
putReq, _ := http.NewRequest("PUT", urlInfo.PresignedURL, file)
putReq.Header.Set("Content-Type", "application/octet-stream")
putRes, err := client.Do(putReq)
if err != nil {
panic(err)
}
putRes.Body.Close()
if putRes.StatusCode >= 400 {
panic(fmt.Sprintf("upload failed with status %d", putRes.StatusCode))
}
}
submitPayload := map[string]any{
"files": func() []map[string]any {
files := make([]map[string]any, len(presignData.PresignedURLs))
for i, urlInfo := range presignData.PresignedURLs {
entry := map[string]any{"path": urlInfo.Path}
if urlInfo.ExternalID != nil {
entry["external_document_id"] = *urlInfo.ExternalID
}
files[i] = entry
}
return files
}(),
}
submitBody, _ := json.Marshal(submitPayload)
submitReq, _ := http.NewRequest("POST", baseURL+"/payroll/entries/payslips", bytes.NewBuffer(submitBody))
submitReq.Header.Set("Authorization", "Bearer "+token)
submitReq.Header.Set("Content-Type", "application/json")
submitRes, err := client.Do(submitReq)
if err != nil {
panic(err)
}
defer submitRes.Body.Close()
bodyBytes, _ := io.ReadAll(submitRes.Body)
fmt.Println(submitRes.Status)
fmt.Println(string(bodyBytes))
}
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import kong.unirest.HttpResponse;
import kong.unirest.Unirest;
String token = "<token>";
String baseUrl = "https://api.sandbox.goteal.co";
ObjectMapper mapper = new ObjectMapper();
HttpResponse<String> presignResponse = Unirest.post(baseUrl + "/payroll/entries/payslips/presign")
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.body("{\n \"files\": [\n {\"file_name\": \"payslip1.pdf\", \"external_id\": \"ext-jan-2024\"},\n {\"file_name\": \"payslip2.pdf\"}\n ]\n}")
.asString();
if (!presignResponse.isSuccess()) {
throw new RuntimeException("Presign request failed: " + presignResponse.getBody());
}
JsonNode presignJson = mapper.readTree(presignResponse.getBody());
for (int i = 0; i < presignJson.get("presigned_urls").size(); i++) {
JsonNode urlInfo = presignJson.get("presigned_urls").get(i);
byte[] pdfBytes = java.nio.file.Files.readAllBytes(java.nio.file.Path.of("./fixtures/payslip" + (i + 1) + ".pdf"));
HttpResponse<String> putResponse = Unirest.put(urlInfo.get("presigned_url").asText())
.header("Content-Type", "application/octet-stream")
.body(pdfBytes)
.asString();
if (!putResponse.isSuccess()) {
throw new RuntimeException("Upload failed: " + putResponse.getBody());
}
}
HttpResponse<String> submitResponse = Unirest.post(baseUrl + "/payroll/entries/payslips")
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.body(presignJson.toString().replace("presigned_urls", "files"))
.asString();
if (!submitResponse.isSuccess()) {
throw new RuntimeException("Submit failed: " + submitResponse.getBody());
}
System.out.println(submitResponse.getBody());
{
"account_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"entry_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"payroll_submissions": [
{
"id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"account_id": "674744df-9626-47ef-ae2b-4a491be136b5",
"entry_id": "be770ba4-1362-46cd-8c1c-2330ce3a8b69",
"created_at": "2019-05-17T00:00:00.000Z",
"document_external_id": "payslip123456",
"document_filename": "file1-payslip.pdf",
"source": "Payslip (via Doc Scan)",
"identity_information": {
"name": "John Smith",
"date_of_birth": "2019-05-17T00:00:00.000Z",
"address": {
"street": "123 Main Street",
"county": "Greater London",
"city": "London",
"post_code": "SW1A 1AA",
"country": "United Kingdom"
},
"email": "john.smith@company.com",
"phone": 447123456789,
"NI_number": "AB123456C"
},
"employment_information": {
"employer_name": "Acme Ltd",
"role": "Software Engineer",
"type": "Full-time",
"status": "Active",
"start_date": "2019-05-17T00:00:00.000Z",
"leave_date": "2019-05-17T00:00:00.000Z"
},
"income_information": {
"pay_date": "2023-05-27T00:00:00.000Z",
"pay_interval_start": "2023-05-01T00:00:00.000Z",
"pay_interval_end": "2023-05-31T00:00:00.000Z",
"pay_frequency": "Monthly",
"earnings": {
"gross_pay": 3500,
"net_pay": 2500,
"base_salary": 3000,
"bonus": 500
},
"deductions": {
"income_tax": 500,
"employee_ni": 200,
"employee_pension": 300,
"total_deductions": 1000
}
}
},
"trust_score": "High"
],
"payslip_errors" : [{
"error" : "File is not a payslip",
"file_name" : "Payslip3.pdf"
}]
}
Authorizations
Bearer token for authentication. The token should be the one returned by the /user-tokens endpoint.
Headers
Optional authorisation ID to associate with this request. If not provided, the system will attempt to resolve a valid authorisation for the user automatically.
Example:
"7f3b8c2a-1d4e-4f6a-8b8c-9a0b1c2d3e4f"
Body
application/json
Minimum array length:
1Show child attributes
Show child attributes
Response
Created
The id of the account
Example:
"95a0e70b-fe02-4f47-aef9-2efff279df71"
The id of the entry
Show child attributes
Show child attributes
The id of the authorisation that authorized this data retrieval
Example:
"7f3b8c2a-1d4e-4f6a-8b8c-9a0b1c2d3e4f"
List of errors for payslips that passed validation but failed to upload
Show child attributes
Show child attributes
⌘I