# 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. Postman Environment ### 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:** payroll connection **Submit a Payslip:** payslip submission ## 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: OAuth2 Login 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 | |