# Create an account
Source: https://docs.goteal.co/api-reference/accounts/create
POST /accounts
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](/authorisations) for more details.
# Delete an account
Source: https://docs.goteal.co/api-reference/accounts/delete
DELETE /accounts/{account_id}
# Direct Connection
Source: https://docs.goteal.co/api-reference/accounts/direct-connections-create
POST /accounts/direct-connections
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](/authorisations) for more details.
# Get an account
Source: https://docs.goteal.co/api-reference/accounts/get
GET /accounts/{account_id}
# List all accounts
Source: https://docs.goteal.co/api-reference/accounts/list
GET /accounts
# List all recurring accounts
Source: https://docs.goteal.co/api-reference/accounts/list-recurring
GET /accounts/recurring
# Submit MFA response
Source: https://docs.goteal.co/api-reference/accounts/mfa
PUT /accounts/{account_id}/mfa
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](/authorisations) for more details.
# Record Authorisation Acceptance
Source: https://docs.goteal.co/api-reference/authorisations/create
POST /authorisations
# List User Authorisations
Source: https://docs.goteal.co/api-reference/authorisations/list
GET /authorisations
# Revoke an Authorisation
Source: https://docs.goteal.co/api-reference/authorisations/revoke
PUT /authorisations/{authorisation_id}/revoke
# Create an Authorisation Term
Source: https://docs.goteal.co/api-reference/authorisations/terms-create
POST /authorisations/terms
# Get an Authorisation Term
Source: https://docs.goteal.co/api-reference/authorisations/terms-get
GET /authorisations/terms/{term_id}
# Get Latest Term for User
Source: https://docs.goteal.co/api-reference/authorisations/terms-latest
GET /authorisations/terms/latest
# List Authorisation Terms
Source: https://docs.goteal.co/api-reference/authorisations/terms-list
GET /authorisations/terms
# Get client configuration
Source: https://docs.goteal.co/api-reference/configuration/get
GET /configuration
# Update client configuration
Source: https://docs.goteal.co/api-reference/configuration/update
PATCH /configuration
Partial updates supported; omitted fields preserve their existing values.
# List bank statement submissions
Source: https://docs.goteal.co/api-reference/documents/bank-statements-get
GET /bankstatements/{user_id}
Retrieve paginated bank statement submissions for a given user.
Each submission includes the extracted data (account information,
statement period, balance information, and transactions) when available.
# Presign bank statement uploads
Source: https://docs.goteal.co/api-reference/documents/bank-statements-presign
PUT /documents/bank-statements/presign
Generates short-lived presigned URLs that you can use to upload bank
statement PDFs directly to Teal-managed storage. Use this endpoint
before uploading any files and submitting them for processing.
# Upload bank statements
Source: https://docs.goteal.co/api-reference/documents/bank-statements-upload
PUT /documents/bank-statements
Uploading bank statements is a three-step flow:
1. Request upload slots with `PUT /documents/bank-statements/presign`,
providing each PDF file name (and optional external identifier).
The response includes `presigned_url` and `path` values for every
requested file.
2. Upload the PDF content to every `presigned_url` using an HTTP `PUT`
request with `Content-Type: application/octet-stream`.
3. Call this endpoint with the JSON payload returned in step one,
echoing the `path` (and optional `external_document_id`) for every
uploaded file so Teal can extract and store the bank statement data.
The `path` values you submit must match the ones returned by the presign
step. Any files that failed validation or extraction are reported in the
`bank_statement_errors` array.
# Delete a document
Source: https://docs.goteal.co/api-reference/documents/delete
DELETE /documents/{document_id}
# Retrieve a specific document
Source: https://docs.goteal.co/api-reference/documents/get
GET /documents/{document_id}
# Lists all documents
Source: https://docs.goteal.co/api-reference/documents/list
GET /documents
# Get ONS expenditure
Source: https://docs.goteal.co/api-reference/expenditure/ons
GET /expenditure/{user_id}/ons
Estimates cost of living and housing from the user's payroll periods,
date of birth, and postcode using the Teal V3.0 ONS Family Spending
model (table A6).
Net pay is split across calendar months by overlapping days. Age is
taken as of the last day of each month from that period's date of
birth (else the user's) and adjusts cost of living only.
Region (from each period's postcode prefix, else the user's first
postcode that has an ONS region) adjusts housing only.
Months are skipped, not returned, when their net pay is zero or
negative (refunds, reversals) or when no postcode for the month has
an ONS region (for example GY, JE, IM or an unknown postcode).
`404` lists each missing or invalid input as its own string in
`errors`:
- `User [] not found`
- `[date_of_birth] param is missing on the user and its payslips`
- `[post_codes] param is missing on the user and its payslips`
- `[date_of_birth] param is after the payslip month`
- `[date_of_birth] param gives an age over 150, the ONS model limit`
- `Postcode has no ONS region; supported: England, Wales, Scotland, Northern Ireland`
(only when no month has a postcode with a region)
- `payroll periods are missing`
- `payroll periods have no positive net pay`
Monthly cost of living and housing estimates from the Teal V3.0 ONS Family Spending model (table A6). `expenditures` is newest month first.
Cost of living is age-adjusted from each payroll period's `identity_information.date_of_birth`, falling back to the user's `date_of_birth`. Housing is region-adjusted from each period's `identity_information.address.post_code`, falling back to the user's first `post_codes` entry that has an ONS region. Age is calculated as of the last day of each month.
Some months are left out of `expenditures`:
* Months whose net pay is zero or negative (refunds, reversals). They are skipped rather than returned as `0.00` or negative amounts.
* Months where neither the payroll period's postcode nor any of the user's `post_codes` has an ONS region (for example GY, JE, IM or an unknown postcode).
`404` returns `{ "errors": [...] }` with one string per missing or invalid input:
| Error | Cause |
| - | - |
| `User [] not found` | No such user for this client |
| `[date_of_birth] param is missing on the user and its payslips` | No date of birth on a month's payroll period or on the user |
| `[post_codes] param is missing on the user and its payslips` | No postcode on a payroll period or on the user |
| `[date_of_birth] param is after the payslip month` | Date of birth is after the month it applies to, for example in the future |
| `[date_of_birth] param gives an age over 150, the ONS model limit` | Age is above the model's oldest band |
| `Postcode has no ONS region; supported: England, Wales, Scotland, Northern Ireland` | No month has a postcode with an ONS region. One string per postcode tried |
| `payroll periods are missing` | No payroll periods with net pay and dates |
| `payroll periods have no positive net pay` | Every month has zero or negative net pay |
# Query Gov Connection
Source: https://docs.goteal.co/api-reference/gov-connect/connections-create
POST /gov-connect/entries/connections
Retrieves the most recent income data for the account's authorisation. May return an empty result if no new income is found.
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](/authorisations) for more details.
# Get Gov Connect income
Source: https://docs.goteal.co/api-reference/gov-connect/get
GET /gov-connect/{user_id}
# Query Payroll Connection
Source: https://docs.goteal.co/api-reference/payroll/connections-create
POST /payroll/entries/connections
Retrieves the most recent payroll data for the account's authorisation. May return an empty result if no new payroll is found.
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](/authorisations) for more details.
# Delete an entry
Source: https://docs.goteal.co/api-reference/payroll/entries-delete
DELETE /payroll/entries/{entry_id}
# List all entries
Source: https://docs.goteal.co/api-reference/payroll/entries-list
GET /payroll/entries
# Get Payroll
Source: https://docs.goteal.co/api-reference/payroll/get
GET /payroll/{user_id}
# Payslips upload
Source: https://docs.goteal.co/api-reference/payroll/payslips-create
POST /payroll/entries/payslips
Uploading payslips is a three-step flow:
1. Request upload slots with `POST /payroll/entries/payslips/presign`, providing
each PDF file name (and optional external identifier). The response
includes `presigned_url` and `path` values for every requested file.
2. Upload the PDF content to every `presigned_url` using an HTTP `PUT`
request with `Content-Type: application/octet-stream`. Each file must be a
searchable, machine-readable PDF (scanned images are not supported).
3. Call this endpoint with the JSON payload returned in step one,
echoing the `path` (and optional `external_document_id`) for every
uploaded file so Teal can process the documents.
The `path` values you submit must match the ones returned by the presign
step. Any files that failed validation or upload are reported in the
`payslip_errors` array.
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](/authorisations) for more details.
## End-to-end upload flow
Follow these steps to upload payslips programmatically:
1. **Request upload slots** – call `POST /payroll/entries/payslips/presign` with the list
of filenames (and optional external identifiers) you intend to upload. The
response returns `presigned_url` and `path` fields for each file.
2. **Upload PDFs** – for every returned `presigned_url`, perform an HTTP `PUT`
with `Content-Type: application/octet-stream`, streaming the PDF bytes. Files must be
searchable, machine-readable PDFs (scanned images are not supported).
3. **Submit metadata** – call `POST /payroll/entries/payslips` (this endpoint) with a
JSON payload that includes the `path` for each uploaded file and an optional
`external_document_id`. Teal uses this metadata to process the uploaded
documents. Any files that fail validation are listed in `payslip_errors` in
the response.
```bash cURL theme={null}
# Step 1: request presigned upload URLs
curl --request POST \
--url https://api.sandbox.goteal.co/payroll/entries/payslips/presign \
--header 'Authorization: Bearer ' \
--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 "" \
--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 ' \
--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"}
]
}'
```
```python Python theme={null}
import json
import requests
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))
```
```javascript JavaScript theme={null}
import axios from 'axios'
const 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 PHP theme={null}
';
$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);
```
```go Go theme={null}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
func main() {
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))
}
```
```java Java theme={null}
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import kong.unirest.HttpResponse;
import kong.unirest.Unirest;
String token = "";
String baseUrl = "https://api.sandbox.goteal.co";
ObjectMapper mapper = new ObjectMapper();
HttpResponse 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 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 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());
```
```json Response theme={null}
{
"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.00",
"net_pay": "2500.00",
"base_salary": "3000.00",
"bonus": "500.00"
},
"deductions": {
"income_tax": "500.00",
"employee_ni": "200.00",
"employee_pension": "300.00",
"total_deductions": "1000.00"
}
},
"trust_score": "High"
}
],
"payslip_errors" : [{
"error" : "File is not a payslip",
"file_name" : "Payslip3.pdf"
}]
}
```
# Generate presigned payslip URLs
Source: https://docs.goteal.co/api-reference/payroll/payslips-presign
POST /payroll/entries/payslips/presign
Generates short-lived presigned URLs that you can use to upload payslip PDFs
directly to Teal-managed storage. Use this endpoint before uploading any
files and submitting them for processing.
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](/authorisations) for more details.
Request short-lived URLs to upload payslip PDFs directly to Teal managed storage.
Use the returned `presigned_url` values with HTTP `PUT`, then call
`POST /payroll/entries/payslips` to submit the uploaded documents for processing.
```bash cURL theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/payroll/entries/payslips/presign \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json' \
--data '{
"files": [
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"}
]
}'
```
```python Python theme={null}
import json
import requests
token = ""
url = "https://api.sandbox.goteal.co/payroll/entries/payslips/presign"
payload = {
"files": [
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"},
]
}
response = requests.post(
url,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
print(json.dumps(response.json(), indent=2))
```
```javascript JavaScript theme={null}
import axios from 'axios'
const token = ''
const response = await axios.post(
'https://api.sandbox.goteal.co/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'
}
}
)
console.log(response.data)
```
```php PHP theme={null}
';
$payload = json_encode([
'files' => [
['file_name' => 'payslip1.pdf', 'external_id' => 'ext-jan-2024'],
['file_name' => 'payslip2.pdf'],
],
]);
$curl = curl_init('https://api.sandbox.goteal.co/payroll/entries/payslips/presign');
curl_setopt_array($curl, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
],
]);
$response = curl_exec($curl);
if ($response === false) {
throw new RuntimeException('cURL error: ' . curl_error($curl));
}
$statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($statusCode >= 400) {
throw new RuntimeException('Request failed: ' . $response);
}
print_r(json_decode($response, true));
```
```go Go theme={null}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
payload := map[string]any{
"files": []map[string]any{
{"file_name": "payslip1.pdf", "external_id": "ext-jan-2024"},
{"file_name": "payslip2.pdf"},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest(
"POST",
"https://api.sandbox.goteal.co/payroll/entries/payslips/presign",
bytes.NewBuffer(body),
)
req.Header.Set("Authorization", "Bearer ")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
panic(fmt.Sprintf("request failed: %s", string(respBody)))
}
respBody, _ := io.ReadAll(resp.Body)
fmt.Println(string(respBody))
}
```
```java Java theme={null}
import kong.unirest.HttpResponse;
import kong.unirest.Unirest;
String token = "";
HttpResponse response = Unirest.post("https://api.sandbox.goteal.co/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 (!response.isSuccess()) {
throw new RuntimeException("Presign request failed: " + response.getBody());
}
System.out.println(response.getBody());
```
```json Response theme={null}
{
"presigned_urls": [
{
"file_name": "payslip1.pdf",
"external_id": "ext-jan-2024",
"presigned_url": "https://s3.amazonaws.com/bucket/presigned-uploads/payslip1.pdf?signature=abc123",
"path": "client/acct123/users/user456/payslip1.pdf"
},
{
"file_name": "payslip2.pdf",
"presigned_url": "https://s3.amazonaws.com/bucket/presigned-uploads/payslip2.pdf?signature=def456",
"path": "client/acct123/users/user456/payslip2.pdf"
}
]
}
```
# List all providers
Source: https://docs.goteal.co/api-reference/providers/list
GET /providers
# Quickstart
Source: https://docs.goteal.co/api-reference/quickstart
Welcome to the comprehensive guide to use Teal API, a pivotal resource for accessing real-time payroll and employment verification data.
## Overview
Teal is a payroll API platform, streamlining the entire income verification process by connecting you to data sourced from payroll and HR systems or analysing income documents, all with the user authorisation at the core.
Teal supports you during the underwriting process, increasing both speed and accuracy levels. With this information you can assess the level of affordability of a borrower, by verifying their level of income and their employment status.
# Authentication
Authentication is a critical aspect of securing access to our API, ensuring that only authorized users can perform operations. We employ API key authentication, a method chosen for its simplicity and effectiveness in safeguarding our services. Upon registration or on request, users are provided with a unique API key via email, which must be included in all API requests.
## Acquiring an API Key
Upon successful registration or request, an API key is issued to each client application through a secure email. This key is essential for accessing the API and must be safeguarded.
### Key Features:
* **Uniqueness**: Each API key is unique, tied to a client application.
* **Revocable**: API keys can be revoked and regenerated for security.
* **Confidentiality**: Keep your API key confidential to prevent unauthorized use.
## Making Authorized Requests
Include the API key in the request headers to make authorized calls to our API.
### Required Headers:
* `Content-Type: application/json`: Indicates the request body is JSON formatted.
* `X-API-KEY: `: Your issued API key.
### Example Request:
```bash theme={null}
curl --request GET \
--url https://api.sandbox.goteal.co/endpoint \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: '
```
Authorization via API key is essential for secure access to our API. Include the necessary headers in your requests to utilize our API's features safely.
## Environments
Our API offers two environments to cater to different stages of development and production:
* **Production:** For real-world use. Base URL: `https://api.goteal.co`
* **Sandbox:** For testing with simulated data. Base URL: `https://api.sandbox.goteal.co`
# Testing Tools and Execution Methods
Our API documentation supports various testing and execution methods to suit different development workflows, emphasizing flexibility and accessibility. We provide examples using cURL commands for their universality, but the principles apply across all execution methods.
## Execution Methods:
### 1. Documentation Web Client
* **Interactivity**: Execute API calls directly within the browser through our interactive documentation, ideal for immediate testing without setup.
* **User-Friendly**: Pre-filled requests in a simple interface make it accessible for newcomers to explore API functionalities quickly.
**How to use:**
1. Navigate to an endpoint definition. Example: [Create a user](/api-reference/users/create)
2. Fill in the required API key in the `X-API-KEY` field of the Authorization dropdown.
3. Fill in the required parameters of the Body dropdown.
4. Click "Send".
5. The response will be displayed below the request form.
### 2. Postman
Import our API collection into Postman by pasting this URL into **Import** → **Link**:
```
https://docs.goteal.co/api-reference/openapi.yaml
```
**Tip**:
Remember to set the environment variables in Postman. You can find the environment variables in the top right corner of the Postman interface.
### 3. Terminal with cURL
* **Flexibility**: cURL commands, used for direct API interactions from the command line, support scripting and task automation.
* **Compatibility**: With broad OS support, cURL is a reliable choice for endpoint testing, demonstrated by clear, adaptable examples in our documentation.
### 4. Programmatically
* **Integration-Ready**: Implement API calls in your application code for automated testing or development, facilitating continuous integration workflows.
* **Language Agnostic**: Any programming language capable of making HTTP requests can interact with our API, enabling integration into diverse development environments.
## Technical Considerations:
* **HTTP Methods**: Understand the HTTP methods (GET, POST, DELETE, etc.) our API uses, as they dictate how to interact with each endpoint.
* **Request Headers**: Pay close attention to required headers, especially `Content-Type` and `Authorization`, as they're crucial for successful API calls.
* **Error Handling**: Familiarize yourself with our API's error responses to effectively handle and debug issues during development.
In summary, whether you prefer testing APIs directly from our documentation, using command-line tools like cURL, or integrating API calls into your software, our documentation provides the necessary guidance. The provided cURL examples offer a solid foundation for understanding how to craft requests, applicable across different technologies and platforms.
## Data
After connecting a new account, data is retrieved in stages:
* **Instant Data:** Quickly available post-connection, covering static information that changes infrequently.
* **Event Data:** Compiled over time, providing a historical view that can extend back several years.
We continuously update and enrich your data as new information becomes available.
### Security
To protect your API keys:
* **HTTPS Security**: Always use HTTPS to ensure your API requests are encrypted, protecting your API key and data from eavesdropping.
* **Key Management**: Treat your API key as a sensitive password—regenerate it periodically and immediately replace it if compromised.
* **Secure API Key Storage**: Store your API key securely, avoiding exposure in client-side code or publicly accessible areas.
### Handling Responses
Our API uses standard HTTP status codes for feedback:
* `200 OK`: Request successful.
* `201 Created`: New resource created.
* `204 No Content`: Successfully processed, no content to return.
* `400 Bad Request`: Invalid request format.
* `401 Unauthorized`: Authentication required.
# Create a User
Source: https://docs.goteal.co/api-reference/users/create
POST /users
# Delete a user
Source: https://docs.goteal.co/api-reference/users/delete
DELETE /users/{user_id}
# Retrieve a user
Source: https://docs.goteal.co/api-reference/users/get
GET /users/{user_id}
# List all users
Source: https://docs.goteal.co/api-reference/users/list
GET /users
# List all recurring users
Source: https://docs.goteal.co/api-reference/users/list-recurring
GET /users/recurring
# Generate User token
Source: https://docs.goteal.co/api-reference/users/tokens
POST /user-tokens
# Update a User
Source: https://docs.goteal.co/api-reference/users/update
PATCH /users/{user_id}
Update a user and its config. If a field is sent as null then it is considered an unset or removal.
# Webhooks
Source: https://docs.goteal.co/api-reference/webhooks
Stay updated with real-time notifications via our Webhooks service.
## Overview
Webhooks instantly inform your application about significant events, such as payroll submissions or updates, without the need for polling.
### Use Cases:
* Alerting when users submit or create payroll data. This use case can be handled by subscribing for event of type `user-payroll-submitted`
* Notifying upon successful account linkages. Not yet supported as event type.
* Triggering actions after data updates. Not yet supported as event type.
Webhooks are system-wide meaning subscriptions are not limited to specific users or accounts. For example, if any user updates their payroll with the `user-payroll-submitted` subscription enabled, a webbook will be delivered with the respective user information enclosed.
### Quick Reference:
* **Webhooks List**: Detailed descriptions of all events you can subscribe to.
* **Setup Guide**: Instructions for subscribing and managing webhooks.
### Supported Event:
* `user-payroll-submitted` - notifies when any user creates an entry with new payroll data.
Includes the payroll data entry as json if `include_payload` is set in the configuration.
* `user-bank-statement-submitted` - notifies when any user uploads bank statements that are
successfully extracted. Includes bank statement submissions as json if `include_payload` is set
in the configuration.
## Setting Up Webhooks
Create a webhook by sending a POST request. Specify which events to monitor and where to send notifications.
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/webhooks \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"events": ["user-payroll-submitted", "user-payroll-created"],
"name": "user-payroll-submitted",
"signing_secret": "very-secret",
"encryption_key": "St/73LZ1xHkwX5TanL7V+YAVczn4acozPf3cFySKQXI=",
"url": "https://webhooks.site"
}'
```
* **events**: Choose events like `user-payroll-submitted` or `user-bank-statement-submitted` for targeted notifications.
* **name**: Label your webhook for easier management.
* **url**: Your endpoint to receive webhook payloads.
* **signing\_secret**: (Optional) A secret for secure webhook verification.
* **encryption\_key**: (Optional) A Base64-encoded 32-byte key used to encrypt webhook payloads.
### Verifying Webhooks
If the optional `signing_secret` was provided in the webhook subscription, it can be used to validate incoming webhook
signatures, ensuring data integrity and authenticity.
When encryption is enabled, the message is first encrypted and then signed.
Therefore, the signature should be verified against the encrypted message,
not the decrypted content.
To verify if a received webhook event is authentic:
* Encode the webook payload with the `signing_secret` using `HMAC-SHA512`. You can then check that the result of this encoding matches the contents in the
`X-Teal-Signature` header. If it matches then the webhook received is genuine. Example verification:
If your `signing_secret` was `mysecret` and you received the following webhook payload:
```json theme={null}
{
"event": "user-payroll-submitted",
"user_id": "4708334c-70b3-437d-8e46-91ff5c9a8d7d",
"timestamp": "2024-04-04T12:00:00.00Z"
}
```
The signature to check against would be:
```
"X-Teal-Signature" : "a30540779107a19069257432b775b74b16b32214616638fae2e6027a41a3f2dfb08f44daf3862c335d08fb83501fc769f73d49a1cb137f96f31c6a7db412c197"
```
Example of verifying signature:
```javascript JS theme={null}
const crypto = require('crypto');
// The signature as "X-Teal-Signature" header
const receivedTealSignature = '...';
const client_secret = 'my little secret';
const requestBody = '...'; // Your request body here
// Make sure to use the raw requestBody when validating the signature.
var signature = crypto
.createHmac('sha512', client_secret)
.update(requestBody)
.digest('hex');
if (receivedTealSignature === signature) {
console.log('Signature is valid');
} else {
console.log('Signature is invalid');
}
```
```python Python theme={null}
import hmac
import hashlib
receivedTealSignature = '...' # The signature as "X-Teal-Signature" header
client_secret = 'my little secret'
requestBody = '...' # Your request body here
# Make sure to use the raw requestBody when validating the signature.
signature = hmac.new(
client_secret.encode(),
msg=requestBody.encode(),
digestmod=hashlib.sha512
).hexdigest()
if receivedTealSignature == signature:
print('Signature is valid')
else:
print('Signature is invalid')
```
```java Java theme={null}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public class Main {
public static void main(String[] args) throws Exception {
// The signature as "X-Teal-Signature" header
String receivedTealSignature = "...";
String client_secret = "my little secret";
String requestBody = "..."; // Your request body here
// Make sure to use the raw requestBody when validating the signature.
Mac sha512_HMAC = Mac.getInstance("HmacSHA512");
SecretKeySpec secret_key = new SecretKeySpec(
client_secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA512"
);
sha512_HMAC.init(secret_key);
byte[] hash = sha512_HMAC
.doFinal(requestBody.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(hash.length * 2);
for(byte b : hash) {
sb.append(String.format("%02x", b));
}
String signature = sb.toString();
if (receivedTealSignature.equals(signature)) {
System.out.println("Signature is valid");
} else {
System.out.println("Signature is invalid");
}
}
}
```
```kotlin Kotlin theme={null}
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.nio.charset.StandardCharsets
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
// The signature as "X-Teal-Signature" header
val receivedTealSignature = "..."
val client_secret = "my little secret"
val requestBody = "..." // Your request body here
// Make sure to use the raw requestBody when validating the signature.
val sha512_HMAC = Mac.getInstance("HmacSHA512")
val secret_key = SecretKeySpec(
client_secret.toByteArray(StandardCharsets.UTF_8),
"HmacSHA512"
)
sha512_HMAC.init(secret_key)
val hash = sha512_HMAC
.doFinal(requestBody.toByteArray(StandardCharsets.UTF_8))
val signature = hash.joinToString("") { "%02x".format(it) }
if (receivedTealSignature == signature) {
println("Signature is valid")
} else {
println("Signature is invalid")
}
```
### Generating Encryption Keys
If you want to use encrypted webhooks, you'll need to provide a 32-byte encryption key encoded in Base64 when creating your webhook. Here's how to generate a secure random key in different languages:
```javascript JS theme={null}
const crypto = require('crypto');
// Generate a random 32-byte key
const encryptionKey = crypto.randomBytes(32);
// Encode the key as Base64 for use with the API
const encryptionKeyBase64 = encryptionKey.toString('base64');
console.log('Encryption Key:', encryptionKeyBase64);
// Example output: St/73LZ1xHkwX5TanL7V+YAVczn4acozPf3cFySKQXI=
```
```python Python theme={null}
import os
import base64
# Generate a random 32-byte key
encryption_key = os.urandom(32)
# Encode the key as Base64 for use with the API
encryption_key_base64 = base64.b64encode(encryption_key).decode('utf-8')
print(f"Encryption Key: {encryption_key_base64}")
# Example output: St/73LZ1xHkwX5TanL7V+YAVczn4acozPf3cFySKQXI=
```
```java Java theme={null}
import java.security.SecureRandom;
import java.util.Base64;
public class GenerateEncryptionKey {
public static void main(String[] args) {
// Generate a random 32-byte key
SecureRandom secureRandom = new SecureRandom();
byte[] key = new byte[32];
secureRandom.nextBytes(key);
// Encode the key as Base64 for use with the API
String encryptionKeyBase64 = Base64.getEncoder().encodeToString(key);
System.out.println("Encryption Key: " + encryptionKeyBase64);
// Example output: St/73LZ1xHkwX5TanL7V+YAVczn4acozPf3cFySKQXI=
}
}
```
```kotlin Kotlin theme={null}
import java.security.SecureRandom
import java.util.Base64
// Generate a random 32-byte key
val secureRandom = SecureRandom()
val key = ByteArray(32)
secureRandom.nextBytes(key)
// Encode the key as Base64 for use with the API
val encryptionKeyBase64 = Base64.getEncoder().encodeToString(key)
println("Encryption Key: $encryptionKeyBase64")
// Example output: St/73LZ1xHkwX5TanL7V+YAVczn4acozPf3cFySKQXI=
```
### Decrypting Webhook Payloads
When you provide an `encryption_key`, the webhook payload will be encrypted using AES/GCM/NoPadding. To decrypt:
1. The encrypted payload is sent in the request body
2. The initialization vector is provided in the `X-Teal-Encryption-IV` header (Base64 encoded)
Here's how to decrypt the payload in different languages:
```javascript JS theme={null}
const crypto = require('crypto');
function decrypt(
encryptedBase64,
ivBase64,
encryptionKeyBase64
) {
const encryptionKey = Buffer.from(encryptionKeyBase64, 'base64');
const iv = Buffer.from(ivBase64, 'base64');
const encryptedBuffer = Buffer.from(encryptedBase64, 'base64');
// Extract the auth tag (last 16 bytes) and ciphertext
const authTag = encryptedBuffer.subarray(encryptedBuffer.length - 16);
const ciphertext = encryptedBuffer.subarray(0, encryptedBuffer.length - 16);
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext);
try {
const final = decipher.final();
if (final.length > 0) {
decrypted = Buffer.concat([decrypted, final]);
}
const payload = JSON.parse(decrypted.toString('utf8'));
console.log('Decrypted payload:', payload);
} catch (error) {
console.error('Decryption failed:', error);
}
}
decrypt(
webhookRequest.body,
webhookRequest.headers['x-teal-encryption-iv'],
yourSecretStorage.base64EncryptionKey
)
```
```python Python theme={null}
import base64
import json
from Crypto.Cipher import AES
def decrypt(
encrypted_base64: str,
iv_base64: str,
encryption_key_base64: str
) -> dict:
encryption_key = base64.b64decode(encryption_key_base64)
print(f"Key length: {len(encryption_key)} bytes")
iv = base64.b64decode(iv_base64)
print(f"IV length: {len(iv)} bytes")
encrypted_data = base64.b64decode(encrypted_base64)
print(f"Encrypted data length: {len(encrypted_data)} bytes")
auth_tag = encrypted_data[-16:]
ciphertext = encrypted_data[:-16]
print(f"Auth tag length: {len(auth_tag)} bytes")
print(f"Ciphertext length: {len(ciphertext)} bytes")
cipher = AES.new(encryption_key, AES.MODE_GCM, nonce=iv)
decrypted_data = cipher.decrypt_and_verify(ciphertext, auth_tag)
payload = json.loads(decrypted_data.decode('utf-8'))
print("Decryption successful!")
print(f"Decrypted payload: {payload}")
return payload
if __name__ == "__main__":
decrypt(
webhookRequest.body,
webhookRequest.headers["x-teal-encryption-iv"],
secretStorage.base64Secret
)
```
```java Java theme={null}
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
public class DecryptMessage {
public static String decryptWebhookPayload(
String encryptedData,
String encryptionKey,
String ivBase64
) throws Exception {
byte[] key = Base64.getDecoder().decode(encryptionKey);
byte[] iv = Base64.getDecoder().decode(ivBase64);
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedData);
SecretKeySpec secretKey = new SecretKeySpec(key, "AES");
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec spec = new GCMParameterSpec(128, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec);
byte[] decryptedBytes = cipher.doFinal(encryptedBytes);
return new String(decryptedBytes, "UTF-8");
}
public static void main(String[] args) {
try {
String result = decryptWebhookPayload(
"nBPqEB4T1g/88q1P2qm7oYH5MYbR/NpCVBCytMZsB7ux4xR+OYPS8Y/Ye8giQb6UplDitbKlFlHPoA6pzMu3CmD2cew4JvluyKiOQBE61OwETwJ++KkvwuB9WxegKnhncWqg6qwn9WkRGtmKpfrxiXrdvj7V4aKSOFkO8ByWYztQp58Ublm3yNPQyQmnzJOR+/4v8OMbVivcAkumRu8PO2EYr2uqfPvNMonXiNE5fnqJieZKFvRnDi+s6TNZpSsCzWPgfDGn+EqiDbjqWw+eWnUYd3BQ75OOJficovrzk1dYkONm5uYTnZYSdiwMzNm7",
"7/PxZATSzWbQkS8ZjSt0f+bTMt9oQ6jkm0aYFe3NN24=",
"vdBbe9Tk7aqo1pZi"
);
System.out.println("Decrypted payload: " + result);
} catch (Exception e) {
System.err.println("Decryption failed: " + e.getMessage());
e.printStackTrace();
}
}
}
```
```kotlin Kotlin theme={null}
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
fun decryptWebhookPayload(
encryptedData: String,
encryptionKey: String,
ivBase64: String
): String {
val key = Base64.getDecoder().decode(encryptionKey)
val iv = Base64.getDecoder().decode(ivBase64)
val encryptedBytes = Base64.getDecoder().decode(encryptedData)
val secretKey = SecretKeySpec(key, "AES")
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
val decryptedBytes = cipher.doFinal(encryptedBytes)
return String(decryptedBytes, Charsets.UTF_8)
}
fun main() {
try {
val result = decryptWebhookPayload(
"nBPqEB4T1g/88q1P2qm7oYH5MYbR/NpCVBCytMZsB7ux4xR+OYPS8Y/Ye8giQb6UplDitbKlFlHPoA6pzMu3CmD2cew4JvluyKiOQBE61OwETwJ++KkvwuB9WxegKnhncWqg6qwn9WkRGtmKpfrxiXrdvj7V4aKSOFkO8ByWYztQp58Ublm3yNPQyQmnzJOR+/4v8OMbVivcAkumRu8PO2EYr2uqfPvNMonXiNE5fnqJieZKFvRnDi+s6TNZpSsCzWPgfDGn+EqiDbjqWw+eWnUYd3BQ75OOJficovrzk1dYkONm5uYTnZYSdiwMzNm7",
"7/PxZATSzWbQkS8ZjSt0f+bTMt9oQ6jkm0aYFe3NN24=",
"vdBbe9Tk7aqo1pZi"
)
println("Decrypted payload: $result")
} catch (e: Exception) {
println("Decryption failed: ${e.message}")
e.printStackTrace()
}
}
```
## Webhook Management
### Viewing subscriptions
List your current webhooks with a simple GET request to our webhook endpoint `GET /webhooks`.
### Deleting subscriptions
Unsubscribe using the webhook ID in the request `DELETE /webhooks/{webhook_id}`
## Best Practices
* **Secure Endpoints**: Ensure your webhook URL is HTTPS-secured to safeguard transmitted data.
* **Manage Secrets**: Keep your `signing_secret` and `encryption_key` confidential.
* **Use Encryption**: Consider using the encryption feature when your team doesn't control HTTPS termination and you want payloads to be decrypted only by applications possessing the encryption key. As an example, this could help prevent sensitive or GDPR-regulated information from being logged in systems not designed to handle such data (there might be more cases for your specific situation).
* **Alternative to Encryption**: As an alternative to encryption, consider disabling the payload in webhooks by not setting `include_payload` in your webhook configuration.
* **Regular Review**: Periodically check your webhook subscriptions and adjust as needed to match your application's requirements.
Leverage our webhooks to make your application more dynamic and responsive to changes without the overhead of continuous polling.
## Webhook Example
Example of webhook request body configured to include the payload by setting
`"include_payload": "true"` in POST ` /webhooks` request body.
If the payroll data comes from uploading a payslip the `account_id`
whould be `null` but `document_external_id` and `document_filename` would be populated
is related to uploading a payslip.
```json theme={null}
{
"event": "user-payroll-submitted",
"user_id": "e9b7676b-f107-429c-8d17-cf3fd1362fd3",
"timestamp": "2024-04-17T13:56:03.277Z",
"entry_id": "cc48a12c-0e98-47aa-b281-f203403470d0",
"account_id": "f68961c9-a083-4bfd-a3c6-0ae169b48f35",
"payload": {
"entry_id": "cc48a12c-0e98-47aa-b281-f203403470d0",
"account_id": "f68961c9-a083-4bfd-a3c6-0ae169b48f35",
"payroll_submissions": [
{
"id": "0187c66e-e7e5-811c-b006-2232f00f426a",
"account_id": "f68961c9-a083-4bfd-a3c6-0ae169b48f35",
"entry_id": "cc48a12c-0e98-47aa-b281-f203403470d0",
"created_at": "2024-04-17T13:56:03.118Z",
"document_external_id": null,
"document_filename": null,
"source": "Payroll (via Direct Connection)",
"identity_information": {
"name": "Jane Doe",
"date_of_birth": "1990-04-01",
"address": {
"street": "123 Main St",
"county": "Anyshire",
"city": "Anycity",
"post_code": "AB12 3CD",
"country": "Countryland"
},
"email": "jane.doe@example.com",
"phone": "+1234567890",
"NI_number": "AB123456C"
},
"employment_information": {
"employer_name": "Acme Corp",
"role": "Software Developer",
"type": "Full_Time",
"status": "Active",
"start_date": "2018-06-01",
"leave_date": null,
"employer_tax_reference": "246/JD135",
"is_director": false
},
"income_information": {
"pay_date": "2024-02-15",
"pay_interval_start": "2024-02-01T00:00:00.000Z",
"pay_interval_end": "2024-02-15T00:00:00.000Z",
"pay_frequency": "Weekly",
"earnings": {
"gross_pay": "2000.00",
"net_pay": "1500.00",
"base_salary": "1900.00",
"bonus": "100.00",
"fixed_pay_total": "1900.00",
"variable_pay_total": "100.00",
"earning_elements": [
{
"type": "Bonus",
"amount": "100.00",
"is_fixed": false,
"is_after_tax": false,
"is_after_ni": false,
"is_bik": false
}
],
"year_to_date": {
"gross_pay": "24000.00",
"net_pay": "18000.00",
"total_tax_paid": "3600.00",
"total_ni_paid": "1800.00",
"total_taxable_earnings": "24000.00",
"tax_year": 2024
},
"rolling_12m_income": {
"gross_pay": "104000.00",
"net_pay": "78000.00"
}
},
"deductions": {
"income_tax": "300.00",
"employee_ni": "150.00",
"employee_pension": "50.00",
"total_deductions": "500.00",
"loan_deductions": "0.00",
"deductions_elements": [
{
"type": "Pension",
"amount": "50.00"
}
]
},
"tax_code": "1257L"
}
}
]
}
}
```
### Bank Statement Webhook Example
Example of a `user-bank-statement-submitted` webhook request body configured with `include_payload` enabled.
The `document_external_ids` array contains the external IDs provided during upload, if any.
When `include_payload` is enabled, the `payload` object contains the full bank statement
submission data including extracted account information, balances, and transactions.
```json theme={null}
{
"event": "user-bank-statement-submitted",
"user_id": "e9b7676b-f107-429c-8d17-cf3fd1362fd3",
"timestamp": "2024-04-17T13:56:03.277Z",
"document_external_ids": ["ext-stmt-2024-01"],
"payload": {
"bank_statement_submissions": [
{
"id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"created_at": "2024-04-17T13:56:03.118Z",
"document_id": "a9249254-ab10-4a2d-b709-eda95f5ecd59",
"document_external_id": "ext-stmt-2024-01",
"document_filename": "hsbc-january-2024.pdf",
"account_information": {
"account_holder_name": "JANE DOE",
"account_number": "12345678",
"sort_code": "12-34-56",
"bank_name": "HSBC",
"iban": null
},
"statement_period": {
"start_date": "2024-01-01",
"end_date": "2024-01-31"
},
"balance_information": {
"opening_balance": "1,000.00",
"closing_balance": "1,250.00"
},
"transactions": [
{
"date": "2024-01-15",
"description": "Salary Payment",
"amount": "2,500.00",
"type": "credit"
},
{
"date": "2024-01-20",
"description": "Rent",
"amount": "-1,250.00",
"type": "debit"
}
],
"bank_statement_probability": 0.95
}
]
}
}
```
# Create a Webhook
Source: https://docs.goteal.co/api-reference/webhooks/create
POST /webhooks
# Delete a Webhook
Source: https://docs.goteal.co/api-reference/webhooks/delete
DELETE /webhooks/{webhook_id}
# Retrieve a Webhook
Source: https://docs.goteal.co/api-reference/webhooks/get
GET /webhooks/{webhook_id}
# List all webhooks
Source: https://docs.goteal.co/api-reference/webhooks/list
GET /webhooks
# Authorisations
Source: https://docs.goteal.co/authorisations
Manage user authorisation for data sharing and access
Authorisation management allows you to track and verify user authorisation for data sharing. Teal provides default authorisation terms, or you can create custom terms tailored to your business requirements.
## Authorisation Terms
Authorisation terms define the legal text that users agree to when granting access to their data.
### System Terms vs Custom Terms
| Type | Description |
| - | - |
| **System Terms** | Default terms provided by Teal, available to all clients. Identified by `is_system_term: true`. |
| **Custom Terms** | Terms created by your organization for specific use cases. Identified by `is_system_term: false`. |
### Categories
Each term belongs to a category that defines the type of authorisation:
* **one\_time** - Single authorisation for a specific action (e.g., one-time income verification). One-time authorisations expire after 1 day; the user must re-authorise if more data needs to be fetched.
* **recurring** - Ongoing authorisation for continuous data access (e.g., recurring payroll checks)
### Retrieving Available Terms
```bash theme={null}
curl --request GET \
--url 'https://api.sandbox.goteal.co/authorisations/terms' \
--header 'X-API-KEY: '
```
Filter by type using query parameters:
```bash theme={null}
# Get only system terms
GET /authorisations/terms?is_system_term=true
# Get only recurring terms
GET /authorisations/terms?authorisation_category=recurring
# Combine filters
GET /authorisations/terms?is_system_term=true&authorisation_category=recurring
```
### Creating Custom Terms
If system terms don't meet your requirements, create custom terms:
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/authorisations/terms \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"name": "Custom Data Sharing Agreement",
"authorisation_text": "I authorize Example Corp to access my payroll data for income verification purposes.",
"authorisation_category": "recurring"
}'
```
The `authorisation_version` is automatically assigned and incremented for each new term you create.
### Managing Active Terms
By default, creating a new term keeps any existing active terms in the same category active. Use the optional `deactivate_previous` flag to control this behavior:
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/authorisations/terms \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"name": "Updated Data Sharing Agreement",
"authorisation_text": "Updated authorisation text...",
"authorisation_category": "recurring",
"deactivate_previous": true
}'
```
| `deactivate_previous` | Behavior |
| - | - |
| `false` (default) | New term is created. Existing active terms remain active. |
| `true` | New term is created. Any existing active term for the same category is set to `is_active: false`. |
When you deactivate previous terms, users who accepted the old term will need to re-authorise to the new term (see [Re-authorisation Flow](#re-authorisation-flow)).
## Getting the Latest Term for a User
To get the appropriate authorisation term for a user based on their recurring check configuration:
```bash theme={null}
curl --request GET \
--url 'https://api.sandbox.goteal.co/authorisations/terms/latest?user_id=95a0e70b-fe02-4f47-aef9-2efff279df71' \
--header 'X-API-KEY: '
```
This endpoint automatically resolves to either the `one_time` or `recurring` category based on the user's configuration.
## Recording Authorisation
When a user accepts authorisation terms, record their acceptance:
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/authorisations \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"user_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"term_id": "550e8400-e29b-41d4-a716-446655440000",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}'
```
`term_id` is optional. If it is absent in the request, then the latest active term is searched against the user's current setting. For example, if
they have reucrring checks enabled then it will resolve to the latest active term matching this category. If the `term_id` is
supplied you can choose which term the user is accepting, provided it is active. No category matching is done currently for an
explcitly provided term.
### Optional Fields
| Field | Behavior |
| - | - |
| `term_id` | Optional. If not provided, auto-resolves to the latest active term based on user's recurring check configuration. |
| `ip_address` | Optional. If not provided, captured from request headers. Use when recording authorisation via server-to-server calls. |
### Authorisation Record
Each acceptance creates an immutable record containing:
* **id** - Unique authorisation record identifier
* **user\_id** - The user who gave authorisation
* **term\_id** - The specific term version accepted
* **ip\_address** - Captured from request headers or provided in request
* **user\_agent** - Browser or application identifier
* **accepted\_at** - Server timestamp of acceptance
* **expires\_at** - Expiry timestamp for the authorisation. Present for one-time authorisations.
* **status** - `active`, `revoked`, or `expired`
## Validating Authorisation
Before performing actions that require authorisation, verify the user has accepted the current active terms.
### Check User's Authorisation Status
```bash theme={null}
curl --request GET \
--url 'https://api.sandbox.goteal.co/authorisations?user_id=95a0e70b-fe02-4f47-aef9-2efff279df71' \
--header 'X-API-KEY: '
```
### Validation Logic
Each authorisation record includes an `is_valid` field that indicates whether the authorisation is currently valid:
* `is_valid: true` - Authorisation status is `active` AND the associated term is active AND the authorisation has not expired
* `is_valid: false` - Either authorisation is revoked, the term has been deactivated, or the authorisation has expired
If `is_valid` is false because the term was deactivated, the user needs to re-authorise to the new term.
One-time authorisations automatically expire. After expiry, new data cannot be fetched, but existing data can still be read unless the authorisation is revoked.
## Re-authorisation Flow
When you update your terms, you can deactivate the old term using `deactivate_previous: true` (see [Managing Active Terms](#managing-active-terms)). Users who accepted the deactivated term will need to re-authorise.
### Flow
```
1. Create new term with deactivate_previous: true
→ Old term set to is_active: false
→ New term created with is_active: true
2. Check user's authorisation:
- GET /authorisations?user_id={user_id}
- If their term's is_active = false, they need re-authorisation
3. Prompt user to review and accept new terms
4. Record new authorisation:
- POST /authorisations with new term_id
5. Old authorisation record preserved for audit trail
```
### Example: Detecting Outdated Authorisation
```bash theme={null}
# 1. Get current active term
curl --request GET \
--url 'https://api.sandbox.goteal.co/authorisations/terms?authorisation_category=recurring&is_active=true' \
--header 'X-API-KEY: '
# 2. Get user's authorisations
curl --request GET \
--url 'https://api.sandbox.goteal.co/authorisations?user_id=95a0e70b-fe02-4f47-aef9-2efff279df71' \
--header 'X-API-KEY: '
# 3. Compare: if user's term_id doesn't match current active term, prompt for re-authorisation
```
## Revoking Authorisation
Users can revoke their authorisation at any time. Revocation preserves the record for audit purposes.
```bash theme={null}
curl --request PUT \
--url https://api.sandbox.goteal.co/authorisations/7f3b8c2a-1d4e-5f6g-7h8i-9j0k1l2m3n4o/revoke \
--header 'X-API-KEY: '
```
After revocation:
* The authorisation record's `status` changes to `revoked`
* The `revoked_at` timestamp is set
* The record is preserved for compliance and audit purposes
# Data
Source: https://docs.goteal.co/data
Manage your data easily and securely
This documentation is designed to provide developers with detailed insights into our API's structure, focusing on the management, delivery, and security of financial data.
For a step-by-step, runnable walkthrough of these endpoints, see the [Sandbox guide](/sandbox).
## 1. Endpoints
Our API organizes financial data into several key entities, each tailored for specific aspects of data handling and retrieval.
### Users `/users`
Represents the borrowers and end-users that give authorised access and upload their payslips to our API.
**Example:** Retrieve a new user
```bash theme={null}
curl --request GET \
--url https://api.sandbox.goteal.co/users/{user_id} \
--header 'X-API-KEY: '
```
A user is made up of their identity information only.
### Accounts `/accounts`
Accounts represent user-linked connections to payroll and HR platforms, which provide Teal with a stream of income and employment data.
**Example:** Link a payroll account
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/accounts \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"user_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"password": "very-secret-password",
"payroll_provider": "quickbooks",
"user_name": "john.smith@company.com",
"extra_login_params": {
"company_name": "ACME Corp"
}
}'
```
A user can have multiple accounts to the same provider depending on the type of connection. The `POST /accounts` endpoint is a way to initialize a connection to a provider for a given user; Teal offers a separate provider flow for users via the WebUI as documented in [Introduction](/introduction).
Alternatively, `POST /accounts/direct-connections` can be used to establish a connection directly by providing a user ID.
The status of a account determines if it is able to recieve data, needs authorisation or if it is invalid or expired. An account connected via OAuth will not require credentials to be passed into the account creation request. Any credentials that are passed to Teal are encrypted securely. An account creation
will do an initial payroll retrieval using the optional dates supplied; if none are sent with the request we then check for the client specific duration for which to lookback - if this is not configured, we take a default of 36 months.
* Use the optional `extra_login_params` object when a provider requires additional key/value pairs; otherwise omit it.
#### Providers that require extra login parameters:
* Dayforce: Required `extra_login_params.company_name`
### Entries `/payroll/entries`
Entries log each data submission, tracking the origin (account connection or manual upload) and content type of the data.
**Example:** Query and persist data for a valid account connection
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/payroll/entries/connections \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json' \
--data '{
"account_id": "95a0e70b-fe02-4f47-aef9-2efff279df71"
}'
```
For an account, a call to the `/payroll/entries/connections` endpoint will query the connection for data. This endpoint is for adhoc data retrieval for an account; Teal will also collect the payroll data for users via the WebUI as soon
as a valid connection is established. This endpoint can be applied to any account, it does not matter if it was established via your own call to `POST /accounts` or via the Teal WebUI. Note that any new data retrieved via `/payroll/entries/connections` is persisted on Teal's storage and can be retrieved using the `/payroll/{user_id}` endpoint.
When querying a connection, new payroll will be fetched based on the initial period used in account creation; Teal uses the most recent `pay_date` of an account to update the connection with the latest payroll seen after this date.
A manual upload of payslip data using is not linked to an account, but the JSON response of `/payroll/entries/payslips` will be in the same format and the payroll data
can be retrieved in the exact same way. The files for upload must be in PDF format.
### Payroll `/payroll`
Aggregates detailed payroll information obtained through account synchronization or document analysis, including comprehensive earnings and deductions data.
**Example:** Access payroll data
```bash theme={null}
curl --request GET \
--url https://api.sandbox.goteal.co/payroll/{user_id} \
--header 'X-API-KEY: '
```
The structure of the returned data will contain fields on employment, identity and income. Each payroll submission includes a `source` field indicating how the data was obtained:
* **Payroll (via Direct Connection)** – Data retrieved from direct API integrations with payroll providers
* **Payroll (via User Connection)** – Data obtained through user-authenticated scraping of payroll accounts
* **Payslip (via Doc Scan)** – Data extracted from uploaded payslip documents
### Documents `/documents`
Original documents (e.g., payslips) uploaded by users are stored securely, serving as the basis for data extraction and verification.
**Example:** Retrieve a raw payslip
```bash theme={null}
curl --request GET \
--url https://api.sandbox.goteal.co/documents/{document_id} \
--header 'X-API-KEY: '
```
The response data of the above document request will be JSON containing the Base-64 encoded file contents. This can be decoded and transformed back into PDF format.
### Configuration `/configuration`
Configuration represents client-wide settings that control behavior across all users and accounts.
**Example:** Update client configuration
```bash theme={null}
curl --request PUT \
--url https://api.sandbox.goteal.co/configuration \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"payslip_upload_enabled": true,
"payroll_period_months": 24,
"client_logo": "https://cdn.client.com/logo.png",
"client_callback_url": "https://app.client.com/callback",
"recurring_checks_enabled": true,
"recurring_check_frequency": "MONTHLY",
"recurring_check_end_date": "2025-01-10T00:00Z"
}'
```
Use GET `/configuration` to retrieve the current client configuration. Use PUT `/configuration` to create or update configuration; updates are partial—omitted fields keep their existing values. Validation rules: `recurring_check_frequency` must be one of \[WEEKLY, MONTHLY]; `recurring_check_end_date` must be a valid ISO8601 UTC datetime. Some user-level settings (see Users API) can override these client-level defaults.
## 2. Authorisation-Protected Endpoints
Certain endpoints that create or retrieve payroll data on behalf of a user require a valid authorisation record before they can be accessed. This applies to:
* `POST /accounts` – creating a new account
* `PUT /accounts/{account_id}/mfa` – submitting mfa for an account
* `POST /accounts/direct-connections` – establishing a direct connection
* `POST /payroll/entries/connections` – querying a connection for data
* `POST /payroll/entries/payslips/presign` – generating presigned upload URLs
* `POST /payroll/entries/payslips` – submitting payslips for processing
If no active authorisation exists for the user, these endpoints will reject the request. You can optionally include the `x-teal-authorisation-id` header to specify which authorisation to use; otherwise the system will resolve a valid authorisation automatically. See [Authorisations](/authorisations) for full details on creating terms and recording user authorisation.
## 3. Data Delivery
Data delivery mechanisms are designed to ensure timely, secure, and efficient access to financial data, facilitating seamless integration and real-time processing.
### Timing
Data processing is immediate for connected accounts, with payroll information updated in real-time as new data becomes available. Manual uploads are processed within seconds of submission, ensuring prompt data availability.
### Availability
Our API provides round-the-clock access to processed data, supporting queries for real-time and historical financial information. Data is accessible through dedicated endpoints, with responses designed for easy integration into client applications.
### Webhooks
Webhooks offer a proactive approach to data delivery, notifying client applications of specific events, such as new data submissions or updates to existing entries, facilitating immediate action or processing.
**Example:** Configure a webhook for new payroll data
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/webhooks \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"events": [
"user-payroll-submitted"
],
"name": "user-payroll-submitted",
"signing_secret": "very-secret",
"url": "https://webhooks.company.com"
}'
```
## 4. Data Security
Adhering to strict security protocols, our API ensures the integrity and confidentiality of sensitive financial data throughout its lifecycle.
### Data Sharing
Access to data through our API is regulated by user permissions alongside OAuth 2.0 protocols, allowing us to securely access user-authorized data. Upon obtaining this data, we then facilitate its secure transfer to the third party requesting this information. This process ensures that data sharing is both compliant with user authorisation and adheres to stringent privacy standards, safeguarding data integrity during transmission.
### Data Deletion Requests
In compliance with privacy laws, our API supports explicit requests for data deletion, allowing our clients to manage the data footprint of their customers if needed be.
**Example:** Request data deletion
```bash theme={null}
curl --request DELETE \
--url https://api.sandbox.goteal.co/users/{user_id} \
--header 'X-API-KEY: '
```
A deletion of a user deletes all sub-resources that the user is linked to. This includes accounts, payroll and uploaded documents. Once deleted no data can be retreived for that user, nor can any new data be attributed. On an account level, any connection can be revoked by the user at any time.
A deletion of an account removes all entries and payroll associated with it. After deletion, this account can not be retireved nor any data queried from it.
### Date handling
All dates in our API are formatted according to [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601).
Response dates are always returned in UTC, representing the equivalent moment in time regardless of the original time zone offset. For example, 2PM in Berlin:
`2024-02-17T14:00:00+02:00` is returned as `2024-02-17T12:00:00Z`
When submitting dates to the API, you can include offsets, and these will be respected in all date calculations.
When integrating with payroll providers that supply dates without time components (e.g., `2024-02-17`), we standardize these to noon UTC (`2024-02-17T12:00:00Z`) to avoid any ambiguity with date boundaries across time zones.
This should be taken into consideration when submitting your own dates for filtering payroll (such as in `POST /accounts`).
### Client callback
You can configure a client callback that will be redirected to after the end user has completed their journey. This allows the user to finish their flow on your chosen domain. For successful account connections we will redirect with the
`account_id`, `user_id` and `provider` in the query parameters. For payslip uploads we will redirect with the `user_id` and a `message` parameter. On failure we'll attach an `error` parameter alongside the `user_id`.
### Security and Compliance
Our infrastructure employs advanced security measures, including data encryption, regular security audits, and compliance with global data protection standards (e.g., GDPR, CCPA), to safeguard user data against unauthorized access or breaches.
This enhanced documentation provides a clearer, more detailed view of how our API manages, delivers, and secures financial data, emphasizing our commitment to data integrity, security, and user control.
# Introduction
Source: https://docs.goteal.co/introduction
Welcome to the Teal API
Teal is the unified data layer for income and employment verification. Our API allows lenders, proptechs, and background screening platforms to instantly verify an applicant's financial identity and affordability.
Whether the data is retrieved automatically via Direct Connections, through a User Connection (payroll login), or extracted from an uploaded payslip via Doc Scan, Teal normalises the output into a single, standardised JSON schema. Write your underwriting logic once, and let Teal handle the data ingestion.
# The Teal Ecosystem
Integrating with Teal typically involves a hybrid approach, leveraging our backend endpoints alongside our drop-in frontend and management tools.
## 1. Core API
The backbone of your integration. You will use our REST API from your secure backend to create user sessions, trigger frictionless Direct Connections, and retrieve the final, normalised JSON payloads. *Note: For teams that want absolute control, you can bypass our UI entirely and build a custom native frontend that passes credentials directly to these endpoints.*
[Explore the API Reference ->](/api-reference/quickstart)
## 2. Hosted webapp
The fastest path to live for interactive flows. Instead of building your own UI for payroll logins and document uploads, embed our conversion-tested modal into your frontend. The Webapp securely handles the employer search directory, consent screens, User Connections, and Doc Scan routing, seamlessly syncing the results back to your API.
## 3. The Teal Console
Your team's command center. While your code talks to our API, your Risk, Operations, and Developer teams can log into the Teal Console. Use it to visually track applicant timelines, inspect raw parsed data, manage API keys, and configure your webhook endpoints without writing database queries.
# How Teal Works
By default, Teal uses an intelligent waterfall approach to guarantee the highest possible conversion rate for your applicants. However, this routing is entirely configurable. You have the flexibility to enable or disable specific paths—such as turning off document uploads or restricting flows to Direct Connections only—to perfectly match your compliance and user experience requirements.
## 1. Direct Connections
The lowest-friction path. Where available, Teal interacts directly with payroll and HRIS systems via server-to-server APIs to instantly pull verified employment and income data without requiring the user to log in.
## 2. User Connections
If a direct API connection isn't available, Teal prompts the user to securely authenticate with their employer or payroll provider. The user searches for their provider, enters their credentials, and Teal securely retrieves their verified history.
## 3. Doc Scan
If digital connections fail or the user cannot remember their credentials, Teal seamlessly falls back to document upload. Unlike generic OCR, Teal's purpose-built extraction models interrogate the uploaded payslip's metadata, validate the mathematics (Gross vs. Net), and check for pixel anomalies for a better assessment.
# Real-Time Updates via Webhooks
To keep your application in sync without continuous polling, Teal provides secure Webhooks. As soon as an applicant's data is processed—whether from an API connection or a parsed document—we instantly fire a user-payroll-submitted event to your backend.
Our webhooks support HMAC-SHA512 signature verification and AES/GCM payload encryption, ensuring that sensitive financial data is transmitted with enterprise-grade security.
[Learn more about Webhooks ->](/api-reference/webhooks)
# Standardized Output
Regardless of which path the user takes in the waterfall, the data always arrives in your system normalized into the exact same schema. Here is an example of what you will receive:
```JSON theme={null}
{
"payroll_submissions": [
{
"id": "8f3b2a1c-9d4e-48b2-b1c3-7f6e5d4c3b2a",
"account_id": "c9a8b7c6-d5e4-4f3a-9b2c-1d0e9f8a7b6c",
"entry_id": "e5d4c3b2-a1b2-4c3d-8e7f-6a5b4c3d2e1f",
"created_at": "2023-11-28T08:15:32.000Z",
"identity_information": {
"name": "David Sterling",
"date_of_birth": "1988-08-22T00:00:00.000Z",
"address": {
"street": "42 Highfield Road",
"county": "Greater Manchester",
"city": "Manchester",
"post_code": "M19 3LW",
"country": "United Kingdom"
},
"email": "david.sterling@techflow.co.uk",
"phone": 447700900451,
"NI_number": "JL654321D"
},
"employment_information": {
"employer_name": "TechFlow Solutions Ltd",
"role": "Senior Product Manager",
"type": "Full_Time",
"status": "Active",
"start_date": "2020-03-15T00:00:00.000Z",
"leave_date": null,
"employer_tax_reference": "321/TF987",
"is_director": false
},
"income_information": {
"pay_date": "2023-11-28T00:00:00.000Z",
"pay_interval_start": "2023-11-01T00:00:00.000Z",
"pay_interval_end": "2023-11-30T00:00:00.000Z",
"pay_frequency": "Monthly",
"earnings": {
"gross_pay": "5500.00",
"net_pay": "3850.00",
"base_salary": "5000.00",
"bonus": "500.00",
"fixed_pay_total": "5000.00",
"variable_pay_total": "500.00",
"earning_elements": [
{
"type": "Bonus",
"amount": "500.00",
"is_fixed": false,
"is_after_tax": false,
"is_after_ni": false,
"is_bik": false
}
],
"year_to_date": {
"gross_pay": "44000.00",
"net_pay": "30800.00",
"total_tax_paid": "7600.00",
"total_ni_paid": "2800.00",
"total_taxable_earnings": "44000.00",
"tax_year": 2023
},
"rolling_12m_income": {
"gross_pay": "66000.00",
"net_pay": "46200.00"
}
},
"deductions": {
"income_tax": "950.00",
"employee_ni": "350.00",
"employee_pension": "350.00",
"total_deductions": "1650.00",
"loan_deductions": "0.00",
"deductions_elements": [
{
"type": "Pension",
"amount": "350.00"
}
]
},
"tax_code": "1257L"
}
}
]
}
```
# Next Steps
Ready to start building? Here is how to get your first test data flowing.
* Get your API Keys: Contact our team at [hello@goteal.co](mailto:hello@goteal.co) to schedule a quick intro call, and we will provision your Sandbox keys.
* [Explore the Sandbox](/sandbox): Learn how to use our test credentials to simulate successful User Connections, direct connections, and document uploads.
# Recurring Payroll
Source: https://docs.goteal.co/recurring-checks
Learn about and configure recurring payroll
## Introduction
To keep your users' payroll up to date without having to manually query each connection, Recurring Payroll can be enabled for your users. This allows you to specify a time frequency for automated payroll fetches.
The valid frequencies are:
* `WEEKLY` - once a week on Monday 12AM UTC
* `MONTHLY` - once a month on the first day of the month 12AM UTC
## Execution
When a recurring check is executed, all of your eligible users will have their accounts updated with their latest payroll. The payroll is queried from the payroll provider by using the most recent `pay_date` of the account,
retrieving the new data that falls after this. Recurring checks can be enabled for all users or for individual users. This also applies to the recurring check configuration where the frequency and end date of the recurring
check can be applied on the user level or to the client level. If a user has a configuration for a given field, it will always take precedence over the client level configuration. If the user has a partial configuration,
then the fields that are not configured will be taken from the client configuration if present.
## Eligibility
A users account is eligible for a recurring payroll check if the following are all true:
* The account has `Authorised` status
* Multi-factor authentication is disabled for the account
* The account is active
* The user has an active recurring authorisation
Users without an active recurring authorisation will have their accounts ignored during recurring checks, even if all other eligibility criteria are met. Ensure that each user has accepted a valid `recurring` authorisation term before relying on recurring payroll updates. See [Authorisations](/authorisations) for details on recording and managing authorisations.
To see which users are eligible for recurring checks you can call the `GET /users/recurring` endpoint. For a user to be eligible for the recurring check they need to have at least one account that matches
the above criteria. An account is considered active if the payroll provider has not marked it as inactive and the latest `pay_date` for the account is not older than 40 days.
A duplicate account is defined as multiple accounts with the same username and payroll provider combination for a given user. If there are duplicate accounts then Teal will take the one with the most recent activity (time of last fetch of payroll), or the
by the creation time (most recently created). A user can have multiple active accounts with different providers and all of these accounts will be part of the recurring update.
To see which accounts would be part of the recurring schedule for a given user you can call the `GET /accounts/recurring` endpoint.
## Failures
If a fetch fails for an account during the recurring payroll check, then it will be placed on a daily retry schedule until it is successful. The daily retry runs at 3AM UTC. When the failure is due to invalid credentials, expired refresh token or a detection of Multi-Factor authentication, these accounts
are excluded from future checks.
## Sandbox
In the sandbox environment, recurring checks can be enabled but all the data that is returned for the accounts is mocked up. For testing purposes, the frequency of the recurring payroll can be set to `HOURLY` in this environment if you choose.
## Activation
To activate recurring payroll checks please contact Teal with the following information:
* Your chosen frequency
* An optional end date for when you want recurring checks to end. If omitted, the checks will run indefinitely.
# Sandbox
Source: https://docs.goteal.co/sandbox
Using the sandbox environment
## Introduction
The sandbox environment functions as a simulated version of the actual system, designed for testing and development purposes without interacting with real data.
This environment employs mock payroll providers and payslip readers in order to allow developers to evaluate the API interface without compromising real data privacy.
For the conceptual data model behind these endpoints, see [Data](/data).
## Test Personas
The sandbox environment includes three pre-configured test personas with corresponding mock data in the backend. Use these personas to test different workflows throughout this guide.
**Persona 1: Sarah Chen**
```json theme={null}
{
"forename": "sarah",
"surname": "chen",
"ni_number": "AB654321C",
"date_of_birth": "1990-04-22T00:00:00.000Z",
"post_codes": ["E1 6PG"],
"payroll_period_months": 12,
"ext_id": "ext-sarah-chen-001"
}
```
**Persona 2: James O'Connor**
```json theme={null}
{
"forename": "james",
"surname": "oconnor",
"ni_number": "BC765432D",
"date_of_birth": "1985-09-10T00:00:00.000Z",
"post_codes": ["M13 9WL"],
"payroll_period_months": 12,
"ext_id": "ext-james-oconnor-002"
}
```
**Persona 3: Amina Diallo**
```json theme={null}
{
"forename": "amina",
"surname": "diallo",
"ni_number": "CD876543E",
"date_of_birth": "1978-12-03T00:00:00.000Z",
"post_codes": ["B1 1TT"],
"payroll_period_months": 12,
"ext_id": "ext-amina-diallo-003"
}
```
## Initial Setup
### API Key & Callback URL
Before you begin testing, ensure you have the following:
* An API key for authentication: please ask your Teal connection to supply you with an API key
* A callback URL for receiving webhook notifications: generate your URL in [webhook.site](https://webhook.site/)
### Webhooks Setup `/webhooks`
Configure webhooks to receive notifications for events such as new payroll submissions.
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/webhooks \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"events": [
"user-payroll-submitted",
],
"name": "user-payroll-submitted",
"url": "https://webhooks.company.com"
}'
```
Optional fields: `signing_secret` (for HMAC verification), `encryption_key`
(for AES/GCM payload encryption).
**Response Example:**
```json theme={null}
{
"webhook_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"events": ["user-payroll-submitted"],
"name": "user-payroll-submitted",
"url": "https://webhooks.company.com",
"created_at": "2019-05-17T00:00:00.000Z"
}
```
### Configure Client Settings `/configuration`
Configure client-wide settings that affect sandbox behavior, such as payroll lookback periods and recurring checks.
```bash theme={null}
curl --request PUT \
--url https://api.sandbox.goteal.co/configuration \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"payslip_upload_enabled": true,
"payroll_period_months": 12,
"recurring_checks_enabled": true,
"recurring_check_frequency": "HOURLY",
"recurring_check_end_date": "2027-01-10T00:00Z"
}'
```
**Response Example:**
```json theme={null}
{
"payslip_upload_enabled": true,
"payroll_period_months": 12,
"client_logo": "",
"recurring_checks_enabled": true,
"recurring_check_frequency": "HOURLY",
"recurring_check_end_date": "2027-01-10T00:00:00.000Z",
"hmrc_enabled": true,
"payroll_connections_enabled": true
}
```
In sandbox, you can test the HOURLY frequency (not available in production).
## User Setup
Before starting any journey, complete the Initial Setup (Webhooks and Configuration) and User Setup steps below. These steps only need to be done once.
#### Create a User `/users`
Create a user using one of the test personas defined above. Include the `ni_number` to match the mock data for that persona. In this example, we'll create Sarah Chen:
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/users \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"forename": "sarah",
"surname": "chen",
"ni_number": "AB654321C",
"date_of_birth": "1990-04-22T00:00:00.000Z",
"post_codes": ["E1 6PG"],
"payroll_period_months": 12
}'
```
**Response Example:**
```json theme={null}
{
"user_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"forename": "Sarah",
"surname": "Chen",
"ni_number": "AB654321C",
"date_of_birth": "1990-04-22T00:00:00.000Z",
"post_codes": ["E1 6PG"],
"payroll_period_months": 12,
"created_at": "2019-05-17T00:00:00.000Z"
}
```
#### Generate a User Token `/user-tokens`
After creating a user, generate a token to authenticate subsequent actions for that user. The token ensures that actions such as account connection and payslip uploads are securely attributed to the correct user.
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/user-tokens \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"user_id": "95a0e70b-fe02-4f47-aef9-2efff279df71"
}'
```
**Response Example:**
```json theme={null}
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfbmFtZSI6ImNsaWVudC1uYW1lIiwidXNlcl9pZCI6Ijk1YTBlNzBiLWZlMDItNGY0Ny1hZWY5LTJlZmZmMjc5ZGY3MSIsImlzcyI6ImFwcC5zYW5kYm94LmdvdGVhbC5jbyIsImV4cCI6MTc3NTA0MjQzOH0.abc123"
}
```
#### Authorisation Terms Request `/authorisations`
Before connecting a payroll account, users must accept your terms and conditions. This request records the user's consent:
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/authorisations \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"user_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}'
```
Optional fields: `term_id` (auto-resolves to latest active term if omitted),
`ip_address` (captured from headers if omitted), `fingerprint` (device/browser
fingerprint).
**Response Example:**
```json theme={null}
{
"id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"user_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"term_id": "550e8400-e29b-41d4-a716-446655440000",
"accepted_text": "Teal will securely access and share information periodically from your payroll or payslip with your client, for the duration of your loan term.",
"ip_address": "192.168.1.1",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"status": "active",
"is_valid": true,
"accepted_at": "2019-05-17T00:00:00.000Z",
"revoked_at": null
}
```
## User Journeys
### Direct Connection
This is the simplest way to get started with sandbox direct connections. The `/accounts/direct-connections` endpoint automatically checks all available providers and returns payroll data directly.
**Prerequisites:** Complete Initial Setup (Webhooks, Configuration) and User Setup above.
#### Direct Connection `/accounts/direct-connections`
For sandbox direct connections, use this simplified endpoint that automatically checks all available direct connection providers and returns payroll data directly.
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/accounts/direct-connections \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"user_id": "95a0e70b-fe02-4f47-aef9-2efff279df71"
}'
```
**Response Example:**
```json theme={null}
{
"pagination": {
"offset": 0,
"limit": 12,
"count": 12,
"total_count": 12
},
"payroll_submissions": [
{
"id": "0187c66e-e7e5-811c-b006-2232f00f426a",
"account_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"entry_id": "ddbb2453-1202-4ae8-9024-4365a223e548",
"created_at": "2024-05-03T14:00:13.804Z",
"authorisation_id": "5a299940-9753-4f36-8611-9aa02ba457f6",
"identity_information": {
"name": "Sarah Chen",
"date_of_birth": "1990-04-22T00:00:00.000Z",
"address": {
"street": "45 Commercial Road",
"city": "London",
"post_code": "E1 6PG"
},
"phone": "+44 7700 900123",
"NI_number": "AB654321C"
},
"employment_information": {
"employer_name": "Acme Corp",
"role": "Software Developer",
"type": "Full_Time",
"status": "Active",
"start_date": "2018-06-01",
"employer_tax_reference": "123/AB456",
"is_director": false
},
"income_information": {
"pay_date": "2024-02-15",
"pay_interval_start": "2024-02-01",
"pay_interval_end": "2024-02-15",
"pay_frequency": "Weekly",
"earnings": {
"gross_pay": "2000.00",
"net_pay": "1500.00",
"base_salary": "1900.00",
"bonus": "100.00",
"fixed_pay_total": "1900.00",
"variable_pay_total": "100.00",
"earning_elements": [
{
"type": "Bonus",
"amount": "100.00",
"is_fixed": false,
"is_after_tax": false,
"is_after_ni": false,
"is_bik": false
}
],
"year_to_date": {
"gross_pay": "24000.00",
"net_pay": "18000.00",
"total_tax_paid": "3600.00",
"total_ni_paid": "1800.00",
"total_taxable_earnings": "24000.00",
"tax_year": 2024
},
"rolling_12m_income": {
"gross_pay": "104000.00",
"net_pay": "78000.00"
}
},
"deductions": {
"income_tax": "300.00",
"employee_ni": "150.00",
"employee_pension": "50.00",
"total_deductions": "500.00",
"loan_deductions": "0.00",
"deductions_elements": [
{
"type": "Pension",
"amount": "50.00"
}
]
},
"tax_code": "1257L"
}
}
]
}
```
This endpoint:
* Takes just a `user_id` (no provider selection needed)
* Automatically checks all available direct connection providers
* Creates accounts automatically for any provider with data
* Returns payroll data directly (no separate `/payroll/entries/connections` call needed)
### User Connection
This journey describes connecting to a specific payroll provider. Use this when you need to connect to a particular provider or when the simplified direct connection doesn't meet your needs.
**Prerequisites:** Complete Initial Setup (Webhooks, Configuration) and User Setup above.
#### Scenario-Based Testing
For certain providers, you can test realistic flows by sending specific usernames that match predefined scenarios. These scenarios simulate real-world conditions such as MFA challenges, incorrect credentials, and successful logins. The user you create does not need to match the test user names listed below. The password can be set to any value.
If you send a `user_name` that does **not** match a scenario username, the sandbox will return fake data as normal.
If you use a scenario username but do **not** supply the corresponding payroll date range, no payroll data will be returned.
| Provider | Employer | Test User | Scenario | Username | Payroll Date Range | MFA | MFA Type | MFA Code |
| - | - | - | - | - | - | - | - | - |
| BrightPayCloud | Aim Smarter | Sarah Mitchell | happy\_no\_mfa | `happy_no_mfa` | 2025-08-01 to 2025-08-31 | No | — | — |
| CGI | Department for Environment, Food and Rural Affairs | David Patel | happy\_mfa | `happy_mfa` | 2025-10-01 to 2025-10-31 | Yes | PasswordWithPositions — Pin Code (positions 2, 4, 6) | `123` |
| AdpMyAdp | Brightwell | Emma Richardson | happy\_no\_mfa | `happy_no_mfa` | 2025-05-01 to 2025-05-31 | No | — | — |
| Hero | — | Marcus Thompson | happy\_mfa | `happy_mfa` | 2025-05-01 to 2025-05-31 | Yes | Code — Authenticator App | `140295` |
The available scenario usernames are: `happy_mfa`, `happy_no_mfa`, `wrong_mfa`, `wrong_username`, and `wrong_password`. When sending date filters via the API, ensure they are in ISO 8601 format (e.g. `2025-08-01T00:00:00Z`).
These scenarios can also be used on the sandbox webapp. When testing via the webapp, ensure the user's `payroll_period_months` lookback is set large enough to cover the scenario's payroll date range.
#### Connect a Payroll Account `/accounts`
This simulates the user connecting their payroll account for automatic data retrieval.
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/accounts \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"user_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"payroll_provider": "quickbooks",
"user_name": "john.smith@company.com",
"password": "example-password"
}'
```
Optional fields: `user_name`, `password`, `extra_login_params` (e.g.
`company_name` for Dayforce).
**Response Example:**
```json theme={null}
{
"account_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"payroll_provider": "quickbooks",
"user_name": "john.smith@company.com",
"created_at": "2019-05-17T00:00:00.000Z",
"status": "Authorised"
}
```
Use the optional `extra_login_params` object for provider-specific login fields (omit it when it isn't required). Dayforce connections require setting `extra_login_params.company_name`.
As with production accounts, when connecting we will retrieve payroll immediately. For sandbox, when not using scenarios, the number of payroll submissions generated matches the `payroll_period_months` configuration. If this is set on both the client and user configuration, the user level config takes precedence. For example, if it is set to 7 months, we'll return 8 payroll submissions (including the current month). If it is not set we'll take a default of 36 months. To simulate a gap in the payroll, the `Shape` provider will leave a 3 month gap from the time of the request.
#### Retrieve Submitted Payroll Information `/payroll/entries/connections`
Retrieve latest payroll for a connected account. For sandbox, we'll always return new payroll where the `pay_date` is set to the current day. For `Shape` provider we will still maintain a 3 month gap.
```bash theme={null}
curl --location --request POST 'https://api.sandbox.goteal.co/payroll/entries/connections' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfbmFtZSI6InN5c19jbGllbnQxIiwidXNlcl9pZCI6IjU3YWRiMzg0LWE2ZGEtNGE1Ny05NThhLTU1ZDk5MzdjZWE0MyIsImlzcyI6ImFwcC5zYW5kYm94LmdvdGVhbC5jbyIsImV4cCI6MTcxNTM0NTI5MH0.IxgmSN4XAn7j2jbQu0npwLXQjEQVqcbjmIBO2T-NCAc' \
--header 'Content-Type: application/json' \
--data-raw '{
"account_id" : 552300dd-1e91-48d4-958b-41736c2c0167
}'
```
**Response Example:**
```json theme={null}
{
"account_id": "552300dd-1e91-48d4-958b-41736c2c0167",
"entry_id": "bdc420fe-5e48-4802-a753-659979a2a346",
"payroll_submissions": [
{
"id": "0187c66e-e7e5-811c-b006-2232f00f426a",
"account_id": "552300dd-1e91-48d4-958b-41736c2c0167",
"entry_id": "bdc420fe-5e48-4802-a753-659979a2a346",
"created_at": "2024-05-03T14:00:13.804Z",
"identity_information": {
"name": "Sarah Chen",
"date_of_birth": "1990-04-22T00:00:00.000Z",
"address": {
"street": "45 Commercial Road",
"county": "Greater London",
"city": "London",
"post_code": "E1 6PG",
"country": "United Kingdom"
},
"email": "sarah.chen@example.com",
"phone": "+447700900123",
"NI_number": "AB654321C"
},
"employment_information": {
"employer_name": "Acme Corp",
"role": "Software Developer",
"type": "Full_Time",
"status": "Active",
"start_date": "2018-06-01",
"leave_date": null,
"employer_tax_reference": "123/AB456",
"is_director": false
},
"income_information": {
"pay_date": "2024-02-15",
"pay_interval_start": "2024-02-01T00:00:00.000Z",
"pay_interval_end": "2024-02-15T00:00:00.000Z",
"pay_frequency": "Weekly",
"earnings": {
"gross_pay": "2000.00",
"net_pay": "1500.00",
"base_salary": "1900.00",
"bonus": "100.00",
"fixed_pay_total": "1900.00",
"variable_pay_total": "100.00",
"earning_elements": [
{
"type": "Bonus",
"amount": "100.00",
"is_fixed": false,
"is_after_tax": false,
"is_after_ni": false,
"is_bik": false
}
],
"year_to_date": {
"gross_pay": "24000.00",
"net_pay": "18000.00",
"total_tax_paid": "3600.00",
"total_ni_paid": "1800.00",
"total_taxable_earnings": "24000.00",
"tax_year": 2024
},
"rolling_12m_income": {
"gross_pay": "104000.00",
"net_pay": "78000.00"
}
},
"deductions": {
"income_tax": "300.00",
"employee_ni": "150.00",
"employee_pension": "50.00",
"total_deductions": "500.00",
"loan_deductions": "0.00",
"deductions_elements": [
{
"type": "Pension",
"amount": "50.00"
}
]
},
"tax_code": "1257L"
}
},
{
"id": "0187c66e-e7e5-811c-b006-2232f00f426a",
"account_id": "552300dd-1e91-48d4-958b-41736c2c0167",
"entry_id": "bdc420fe-5e48-4802-a753-659979a2a346",
"created_at": "2024-05-03T14:00:13.804Z",
"identity_information": {
"name": "Sarah Chen",
"date_of_birth": "1990-04-22T00:00:00.000Z",
"address": {
"street": "45 Commercial Road",
"county": "Greater London",
"city": "London",
"post_code": "E1 6PG",
"country": "United Kingdom"
},
"email": "sarah.chen@example.com",
"phone": "+447700900123",
"NI_number": "AB654321C"
},
"employment_information": {
"employer_name": "Acme Corp",
"role": "Software Developer",
"type": "Full_Time",
"status": "Active",
"start_date": "2018-06-01",
"leave_date": null,
"employer_tax_reference": "123/AB456",
"is_director": false
},
"income_information": {
"pay_date": "2024-02-15",
"pay_interval_start": "2024-02-01T00:00:00.000Z",
"pay_interval_end": "2024-02-15T00:00:00.000Z",
"pay_frequency": "Weekly",
"earnings": {
"gross_pay": "2000.00",
"net_pay": "1500.00",
"base_salary": "1900.00",
"bonus": "100.00",
"fixed_pay_total": "1900.00",
"variable_pay_total": "100.00",
"earning_elements": [
{
"type": "Bonus",
"amount": "100.00",
"is_fixed": false,
"is_after_tax": false,
"is_after_ni": false,
"is_bik": false
}
],
"year_to_date": {
"gross_pay": "24000.00",
"net_pay": "18000.00",
"total_tax_paid": "3600.00",
"total_ni_paid": "1800.00",
"total_taxable_earnings": "24000.00",
"tax_year": 2024
},
"rolling_12m_income": {
"gross_pay": "104000.00",
"net_pay": "78000.00"
}
},
"deductions": {
"income_tax": "300.00",
"employee_ni": "150.00",
"employee_pension": "50.00",
"total_deductions": "500.00",
"loan_deductions": "0.00",
"deductions_elements": [
{
"type": "Pension",
"amount": "50.00"
}
]
},
"tax_code": "1257L"
}
},
{
"id": "0187c66e-e7e5-811c-b006-2232f00f426a",
"account_id": "552300dd-1e91-48d4-958b-41736c2c0167",
"entry_id": "bdc420fe-5e48-4802-a753-659979a2a346",
"created_at": "2024-05-03T14:00:13.804Z",
"identity_information": {
"name": "Sarah Chen",
"date_of_birth": "1990-04-22T00:00:00.000Z",
"address": {
"street": "45 Commercial Road",
"county": "Greater London",
"city": "London",
"post_code": "E1 6PG",
"country": "United Kingdom"
},
"email": "sarah.chen@example.com",
"phone": "+447700900123",
"NI_number": "AB654321C"
},
"employment_information": {
"employer_name": "Acme Corp",
"role": "Software Developer",
"type": "Full_Time",
"status": "Active",
"start_date": "2018-06-01",
"leave_date": null,
"employer_tax_reference": "123/AB456",
"is_director": false
},
"income_information": {
"pay_date": "2024-02-15",
"pay_interval_start": "2024-02-01T00:00:00.000Z",
"pay_interval_end": "2024-02-15T00:00:00.000Z",
"pay_frequency": "Weekly",
"earnings": {
"gross_pay": "2000.00",
"net_pay": "1500.00",
"base_salary": "1900.00",
"bonus": "100.00",
"fixed_pay_total": "1900.00",
"variable_pay_total": "100.00",
"earning_elements": [
{
"type": "Bonus",
"amount": "100.00",
"is_fixed": false,
"is_after_tax": false,
"is_after_ni": false,
"is_bik": false
}
],
"year_to_date": {
"gross_pay": "24000.00",
"net_pay": "18000.00",
"total_tax_paid": "3600.00",
"total_ni_paid": "1800.00",
"total_taxable_earnings": "24000.00",
"tax_year": 2024
},
"rolling_12m_income": {
"gross_pay": "104000.00",
"net_pay": "78000.00"
}
},
"deductions": {
"income_tax": "300.00",
"employee_ni": "150.00",
"employee_pension": "50.00",
"total_deductions": "500.00",
"loan_deductions": "0.00",
"deductions_elements": [
{
"type": "Pension",
"amount": "50.00"
}
]
},
"tax_code": "1257L"
}
}
]
}
```
### Payslip Upload
This journey outlines the process for extracting payroll information by uploading a payslip.
**Prerequisites:** Complete Initial Setup (Webhooks, Configuration) and User Setup above.
#### Upload Payslip `/payroll/entries/payslips`
This request simulates the user uploading a payslip to extract payroll information. Extracting payslip information can be done using the endpoint below but will usually be done by the user through the WebApp.
This example uses James O'Connor:
```bash theme={null}
curl --location --request POST 'https://api.sandbox.goteal.co/payroll/entries/payslips' \
--header 'Authorization: Bearer ' \
--form 'file=@""'
```
**Response Example:**
```json theme={null}
{
"account_id": "f2a8e9c1-d3b4-4f5a-9e8d-7c6b5a4f3e2d",
"entry_id": "f5e0a310-d4d0-4db0-b5c3-895a81e26e03",
"payroll_submissions": [
{
"id": "0187c66e-e7e5-811c-b006-2232f00f426a",
"account_id": null,
"entry_id": "f5e0a310-d4d0-4db0-b5c3-895a81e26e03",
"created_at": "2024-05-03T12:48:54.886Z",
"identity_information": {
"name": "James O'Connor",
"date_of_birth": "1985-09-10T00:00:00.000Z",
"address": {
"street": "12 Oxford Road",
"county": "Greater Manchester",
"city": "Manchester",
"post_code": "M13 9WL",
"country": "United Kingdom"
},
"email": "james.oconnor@example.com",
"phone": "+447700900456",
"NI_number": "BC765432D"
},
"employment_information": {
"employer_name": "Acme Corp",
"role": "Software Developer",
"type": "Full_Time",
"status": "Active",
"start_date": "2018-06-01",
"leave_date": null
},
"income_information": {
"pay_date": "2024-02-15",
"pay_interval_start": "2024-02-01T00:00:00.000Z",
"pay_interval_end": "2024-02-15T00:00:00.000Z",
"pay_frequency": "Weekly",
"earnings": {
"gross_pay": "2000.00",
"net_pay": "1500.00",
"base_salary": "1900.00",
"bonus": "100.00"
},
"deductions": {
"income_tax": "300.00",
"employee_ni": "150.00",
"employee_pension": "50.00",
"total_deductions": "500.00"
}
}
}
]
}
```
### HMRC (Gov Connect) Connection
This journey connects a user to HMRC via Gov Connect using the `hmrc` provider.
Instead of returning payroll data immediately, connecting returns an `authorization_url`
(an invite link) that the user follows to authorise HMRC data sharing. Once
authorised, income data is fetched and can be retrieved with
`GET /gov-connect/{user_id}`. You can subscribe to the `user-gov-connect-data-submitted`
webhook event to be notified when new HMRC income data is available.
Ask your Teal connection for the working HMRC provider id to use for
`payroll_provider` in your environment.
**Prerequisites:** Complete User Setup above. HMRC must be enabled for the client/user
(`hmrc_enabled: true`) and the user must have an active authorisation.
#### Connect an HMRC Account `/accounts`
Create an `hmrc` account, selecting the flow via `extra_login_params.flow`. The
flow must be either `non_mtd` or `mtd`.
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/accounts \
--header 'Content-Type: application/json' \
--header 'X-API-KEY: ' \
--data '{
"user_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"payroll_provider": "hmrc",
"extra_login_params": {
"flow": "mtd"
}
}'
```
`ni_number` may optionally be supplied in `extra_login_params`. If the user's NINO is
already saved against the user record it is not needed here.
**Response Example:**
```json theme={null}
{
"account_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"payroll_provider": "hmrc",
"created_at": "2019-05-17T00:00:00.000Z",
"status": "Pending",
"authorization_url": "https://star-client.untied.io/invite/abc123"
}
```
Direct the user to the `authorization_url` to complete HMRC authorisation.
**Self Employed**
For the self-employed flow (`"flow": "mtd"`), the user is redirected to HMRC to authorise
Making Tax Digital access and then returned to Teal's callback. This requires an HMRC
sandbox test user.
Contact Teal to be provisioned an HMRC sandbox test user. You will log in on the HMRC
authorisation screen using the supplied test details.
```json theme={null}
{
"flow": "mtd"
}
```
**Employed**
For the employed flow (`"flow": "non_mtd"`), authorisation is verified against HMRC
using the user's NINO and date of birth, and completion is signalled to Teal via webhook.
The NINO and DOB must correspond to a valid HMRC test taxpayer.
Contact Teal to be provisioned a test user. In sandbox, the
`authorization_url` shows only a dummy page rather than the full HMRC authorisation flow
used in the self-employed journey.
```json theme={null}
{
"flow": "non_mtd"
}
```
#### Retrieve HMRC Income `/gov-connect/{user_id}`
Once the account is authorised, income data is fetched automatically. Retrieve the stored
Gov Connect income submissions for a user (MTD shape).
```bash theme={null}
curl --request GET \
--url https://api.sandbox.goteal.co/gov-connect/{user_id} \
--header 'X-API-KEY: '
```
**Response Example:**
```json theme={null}
{
"pagination": {
"limit": 100,
"offset": 0,
"count": 1,
"total_count": 1
},
"gov_connect_submissions": [
{
"id": "3f1c0b9a-1e2d-4a5b-9c8d-0e1f2a3b4c5d",
"account_id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"entry_id": "1b2c3d4e-5f6a-7b8c-9d0e-1f2a3b4c5d6e",
"authorisation_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"identity": {
"name": "Sarah Chen",
"date_of_birth": "1990-04-22",
"ni_number": "AB654321C"
},
"employments": [
{
"employment": {
"employer_name": "Acme Corp",
"paye_reference": "120/NC15208",
"status": "active",
"start_date": "2018-06-01T00:00Z"
},
"income": {
"tax_year": "2024-25",
"taxable_income": "28100.91",
"tax_paid": "3611.40"
},
"updated_at": "2026-06-05 13:03:39"
}
],
"self_employments": [
{
"business": { "business_id": "XBIS1" },
"income": {
"tax_year": "2024-25",
"turnover": "92500.00",
"other_income": "1200.00",
"tax_deducted_at_source": "0.00"
}
}
],
"properties": [
{
"property_id": "XPIS1",
"tax_year": "2024-25",
"rent_received": "747.45"
}
]
}
]
}
```
#### Fetch HMRC Income `/gov-connect/entries/connections`
Income is fetched automatically when the account is authorised, so this step is only
needed for subsequent retrievals — to pull fresh income data for an already-connected
account. Any new data is persisted and can then be read via `GET /gov-connect/{user_id}`.
This endpoint uses the user's bearer token.
```bash theme={null}
curl --request POST \
--url https://api.sandbox.goteal.co/gov-connect/entries/connections \
--header 'Authorization: Bearer ' \
--header 'Content-Type: application/json' \
--data '{
"account_id": "95a0e70b-fe02-4f47-aef9-2efff279df71"
}'
```
Returns `201 Created` with the income submissions when data is returned, or `200 OK`
with an empty `gov_connect_submissions` array when there is no new data.
## General Data Retrieval
#### Retrieve Payroll Data `/payroll`
Retrieve the payroll data submitted through a connected account or document upload. This example retrieves payroll for Amina Diallo:
```bash theme={null}
curl --request GET \
--url https://api.sandbox.goteal.co/payroll/{user_id} \
--header 'X-API-KEY: '
```
**Response Example:**
```json theme={null}
{
"pagination": {
"count": 14,
"limit": 25,
"offset": 75,
"total_count": 89
},
"payroll_submissions": [
{
"id": "95a0e70b-fe02-4f47-aef9-2efff279df71",
"account_id": "0988b4bd-42dd-4556-95ae-27a906c066ec",
"entry_id": "0d2bcb9b-a7ec-463f-bd7e-aacaa1a190a4",
"created_at": "2019-05-17T00:00:00.000Z",
"identity_information": {
"NI_number": "CD876543E",
"address": {
"city": "Birmingham",
"country": "United Kingdom",
"county": "West Midlands",
"post_code": "B1 1TT",
"street": "78 Victoria Square"
},
"date_of_birth": "1978-12-03T00:00:00.000Z",
"email": "amina.diallo@example.com",
"name": "Amina Diallo",
"phone": 447700900789
},
"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",
"employer_tax_reference": "789/AC345",
"is_director": false
},
"income_information": {
"pay_date": "2019-05-17T00:00:00.000Z",
"pay_interval_start": "2019-05-17T00:00:00.000Z",
"pay_interval_end": "2019-05-17T00:00:00.000Z",
"pay_frequency": "Monthly",
"earnings": {
"gross_pay": "3500.00",
"net_pay": "2500.00",
"base_salary": "3000.00",
"bonus": "500.00",
"fixed_pay_total": "3000.00",
"variable_pay_total": "500.00",
"earning_elements": [
{
"type": "Bonus",
"amount": "500.00",
"is_fixed": false,
"is_after_tax": false,
"is_after_ni": false,
"is_bik": false
}
],
"year_to_date": {
"gross_pay": "42000.00",
"net_pay": "30000.00",
"total_tax_paid": "6000.00",
"total_ni_paid": "2400.00",
"total_taxable_earnings": "42000.00",
"tax_year": 2019
},
"rolling_12m_income": {
"gross_pay": "42000.00",
"net_pay": "30000.00"
}
},
"deductions": {
"income_tax": "500.00",
"employee_ni": "200.00",
"employee_pension": "300.00",
"total_deductions": "1000.00",
"loan_deductions": "0.00",
"deductions_elements": [
{
"type": "Pension",
"amount": "300.00"
}
]
},
"tax_code": "1257L"
},
"document_external_id": null,
"document_filename": null,
"trust_score": "High"
}
]
}
```
## WebApp Usage
Teal's webapp can be tested by accessing `https://app.sandbox.goteal.co/` in the browser.
### Launching the webapp for a user
When you create a user token via `POST /user-tokens`, the response includes a `webapp_link` field:
```json theme={null}
{
"token": "eyJhbG...",
"webapp_link": "https://app.sandbox.goteal.co/r/ZwgQkNEMZ4QDkQ",
"created_at": "2025-01-15T10:30:00.000Z"
}
```
Open (or redirect the user to) the `webapp_link` URL. This establishes an authenticated session via a secure HttpOnly cookie and redirects the user into the webapp — no token appears in the final URL.
### Embedding in an iframe
When embedding the webapp in a cross-origin iframe, `SameSite=Lax` cookies are not available (all modern browsers block cross-site cookie access in iframes). The webapp handles this automatically via the **postMessage protocol**:
1. The webapp loads and detects it is embedded (`window !== window.parent`).
2. It posts `{ type: "teal:session_required" }` to the parent window (origin-restricted to the `callback_url`).
3. The parent responds with `{ type: "teal:session_token", token: "" }` using the `token` from the `/user-tokens` response.
4. The webapp uses the token as an in-memory `Authorization: Bearer` credential for all API calls within that iframe session.
Your parent page must include a message listener:
```javascript theme={null}
window.addEventListener('message', (event) => {
if (event.origin !== 'https://app.yourdomain.goteal.co') return;
if (event.data?.type === 'teal:session_required') {
event.source.postMessage(
{ type: 'teal:session_token', token: YOUR_JWT_TOKEN },
event.origin
);
}
});
```
Set the iframe `src` to `https://app..goteal.co/?callback_url=`. The `callback_url` parameter tells the webapp which origin to trust for the postMessage exchange.
The token is held in memory only — it is never persisted to cookies, localStorage, or sessionStorage within the iframe. The session lasts for the lifetime of the iframe. If the iframe is destroyed and re-created, the parent must respond to a new `teal:session_required` message.
For clients that configure a `client_callback_url`, the webapp also communicates journey outcomes back to the parent page via `postMessage` (type: `teal:journey_outcome`).
You can test the webapp with any of the three personas defined at the top of this guide (Sarah Chen, James O'Connor, or Amina Diallo).
**Connect a Payroll:**
**Submit a Payslip:**
## Cleanup
#### Deleting a User `/users`
After testing, clean up by deleting the test user and their associated data. Use the `user_id` returned when you created the user (from any of the test personas).
```bash theme={null}
curl --request DELETE \
--url https://api.sandbox.goteal.co/users/{user_id} \
--header 'X-API-KEY: '
```
**Response Example:**
```json theme={null}
{
"message": "User deleted successfully."
}
```
## OAuth2 Test Credentials
Some of our providers use an authorisation flow known as OAuth2. This allows the end user to be redirected to their payroll provider login screen, where they can grant authorisation to share their data. Below is an example login page:
The user will be redirected back to teal after authorising.
To test the providers that have the OAuth2 flow available, you can use the following credentials.
| provider | username | password | Extra login params |
| - | - | - | - |
| `freeagent` | [arun@goteal.co](mailto:arun@goteal.co) | mPxD9Tt3hBV5j39 | |