---
name: optitrust-api-integration
description: Use this skill when implementing a client integration with the OptiTrust API for statement bundles, direct-to-blob PDF upload, bundle webhooks, employment confirmations, or PDF signing.
---

# OptiTrust API Integration Skill

Use this file as LLM context when building or reviewing an OptiTrust client integration. Prefer the live Swagger document at `/swagger` for the final schema check, but follow these workflow rules and safety constraints.

## Integration Goal

Build a client that can call OptiTrust's tenant API using an issued tenant API key. The main integration surfaces are:

- Statement bundle management and PDF upload under `/api/bundles`
- Bundle lifecycle webhooks configured per bundle
- Employment confirmation requests under `/api/employment-confirmations`
- PDF certificate sealing under `/api/sign`
- Electronic signature requests for client-supplied PDFs under `/api/signing-requests`

## Required Inputs From The Client

Ask the human/client for these values before writing production code:

- `baseUrl`: the OptiTrust site origin, for example `https://app.example.com`
- `apiKey`: a tenant API key generated in the OptiTrust tenant dashboard
- `webhookUrl`: an HTTPS receiver URL if bundle webhooks are required
- PDF file source and client reference fields for each workflow

Never hard-code the API key or webhook secret in source code. Read secrets from a secret store or deployment environment.

## Non-Production Testing And Access

Contact Us to discuss non-production testing and access options.

## Global API Rules

- Send the tenant API key in the exact header `XApiKey`.
- Use HTTPS for all calls.
- JSON APIs use `Content-Type: application/json`.
- Enums are serialized as strings. Send enum names, not display labels.
- Treat all identifiers and webhook event IDs as idempotency keys where applicable.
- Do not log API keys, webhook secrets, SAS `uploadUri` values, upload tokens, ID numbers, PDF file names, or raw webhook payloads unless logs are explicitly approved for sensitive data.

The application currently has an in-process global limiter of 600 requests per minute, partitioned by the remote IP observed by the application. Because it is global, it also applies to authenticated API calls; it is not a dedicated API quota. Requests rejected by the limiter receive HTTP 429. Proxying and scale-out can affect the effective boundary, so this is not a throughput guarantee.

Important public routes:

| Purpose | Method and path |
| --- | --- |
| Swagger/OpenAPI | `GET /swagger` |
| Swagger JSON | `GET /swagger/v1/swagger.json` |
| List bundles | `GET /api/bundles?status={status}&numberPerPage=10&page=1` |
| Get bundle | `GET /api/bundles/{identifier}` |
| Create bundle | `POST /api/bundles` |
| Update bundle | `PUT /api/bundles/{identifier}` |
| Issue bundle file upload target | `POST /api/bundles/{identifier}/files/upload-target` |
| Complete bundle file upload | `POST /api/bundles/{identifier}/files` |
| Delete bundle | `DELETE /api/bundles/{identifier}` |
| Delete bundle file | `DELETE /api/bundles/{identifier}/files/{fileId}` |
| Submit employment confirmation | `POST /api/employment-confirmations` |
| List employment confirmations | `GET /api/employment-confirmations?status={status}&page=1` |
| Get employment confirmation | `GET /api/employment-confirmations/{id}` |
| Seal PDF with a certificate | `POST /api/sign` as `multipart/form-data` |
| Create signature request | `POST /api/signing-requests` as `multipart/form-data` |
| Get signature request | `GET /api/signing-requests/{id}` |
| Void signature request | `POST /api/signing-requests/{id}/void` |
| Download sealed document | `GET /api/signing-requests/{id}/document` |

## Statement Bundle Workflow

Use this workflow for bank statement verification or transaction extraction.

### 1. Create the bundle

Request:

```http
POST {baseUrl}/api/bundles
XApiKey: {apiKey}
Content-Type: application/json
```

```json
{
  "idnumber": "8001015009087",
  "type": "TransactionExtraction",
  "dateRange": "Last90Days",
  "proofOfAccount": "NotNeeded",
  "webhookUrl": "https://client.example.com/optitrust/webhooks"
}
```

Valid `type` values:

- `AuthenticationOnly`
- `TransactionExtraction`

Valid `dateRange` values:

- `Any`
- `Last30Days`
- `Last60Days`
- `Last90Days`
- `Last6Months`
- `LastYear`

Valid `proofOfAccount` values:

- `NotNeeded`
- `Yes`
- `OnlyForStatementsWithoutAccountHolder`

Response: `201 Created` with a bundle object. Store `identifier`, `webhookSecret` if present, and `files[].id` when files are later returned.

### 2. Request a file upload target

Do not upload PDF bytes to OptiTrust in this step. Ask OptiTrust for a signed one-file blob upload target.

```http
POST {baseUrl}/api/bundles/{identifier}/files/upload-target
XApiKey: {apiKey}
```

Response:

```json
{
  "fileId": "84a60d05-7546-4731-a23a-e41777d891e4",
  "uploadUri": "https://storage.example/optitrust-upload/84a60d05-7546-4731-a23a-e41777d891e4?...",
  "uploadToken": "signed-token"
}
```

Security properties:

- `uploadUri` is scoped to one server-chosen blob name.
- `uploadUri` is HTTPS-only and Create-only.
- `uploadUri` expires after about 20 minutes.
- `uploadToken` proves the `fileId` was issued by OptiTrust and expires after about 30 minutes.

### 3. Upload PDF bytes to blob storage

Upload the PDF directly to the returned `uploadUri`.

```http
PUT {uploadUri}
x-ms-blob-type: BlockBlob
Content-Type: application/pdf

<PDF bytes>
```

The bundle file upload limit is 10 MB. Only `application/pdf` is accepted by the completion step.

### 4. Complete the file attachment

After the blob upload succeeds, notify OptiTrust with JSON metadata. This endpoint is a completion call. It does not accept raw PDF bytes or multipart form data.

```http
POST {baseUrl}/api/bundles/{identifier}/files
XApiKey: {apiKey}
Content-Type: application/json
```

```json
{
  "fileName": "bank-statement.pdf",
  "fileId": "84a60d05-7546-4731-a23a-e41777d891e4",
  "size": 1024,
  "mimeType": "application/pdf",
  "uploadToken": "signed-token"
}
```

Response: `200 OK` with the updated bundle. If `webhookUrl` is configured, OptiTrust sends `bundle.file_added`.

### 5. Track the bundle

Poll or fetch:

```http
GET {baseUrl}/api/bundles/{identifier}
XApiKey: {apiKey}
```

Typical bundle response fields:

- `identifier`: public GUID
- `idNumber`
- `status`: human-readable status
- `dateAdded`
- `filecount`
- `files[]`: `id`, `fileName`, `status`, `result`, `deleted`, `deletedAtUtc`
- `webhookUrl`
- `webhookSecret` when webhooks are enabled
- `webhookDeliveries[]`: recent delivery audit records
- `deleted`

Use `files[].result` for anti-fraud screening outcomes. `status` is a display label and `bundle.status` is workflow state, not the screening verdict.

`files[].result` fields:

- `processingState`: `pending`, `processing`, `completed`, or `deleted`
- `code`: stable OptiTrust result code, matching the `FileCheckType` enum name, such as `ReadableDocument`, `StatementHasIssues`, `FraudulentBankStatement`, `EditedBankStatement`, `UnknownBankStatementType`, or `ToBeProcessed`
- `label`: human-readable label for the code
- `verdict`: `pending`, `pass`, `review`, `fail`, `unable_to_verify`, `not_applicable`, `manually_approved`, or `deleted`
- `riskBand`: `low`, `elevated`, `high`, or `not_applicable`
- `riskScore`: 0-100 when the file is a scoreable bank statement; `null` for not-applicable categories
- `issues[]`: public issue summaries as `{ "level": "danger|warning|info", "message": "..." }`
- `manuallyApproved`: true when OptiTrust has manually approved issues on the file
- `flaggedForReview`: true when the file has been explicitly flagged for review

Do not expect extracted banking data in the result object. Account numbers, transaction rows, and other statement data are intentionally excluded from the public API result contract.

Bundle statuses currently include:

- `AwaitingDocuments`
- `ToBeReviewd`
- `Completed`

## Bundle Webhooks

Configure bundle callbacks by setting `webhookUrl` on create or update. A blank value disables callbacks. The URL must be HTTPS and must be safe for outbound server calls.

Events:

- `bundle.created`
- `bundle.updated`
- `bundle.deleted`
- `bundle.status_changed`
- `bundle.file_added`
- `bundle.file_deleted`

Webhook headers:

- `X-OptiTrust-Event-Id`: idempotency key for the delivery
- `X-OptiTrust-Event`: event name
- `X-OptiTrust-Timestamp`: Unix timestamp in seconds
- `X-OptiTrust-Signature`: `sha256=<hex>`

Signature verification:

1. Read the exact raw JSON request body bytes/string before parsing.
2. Build this UTF-8 string: `{timestamp}.{eventId}.{rawJsonBody}`.
3. Compute HMAC-SHA256 with the bundle `webhookSecret`.
4. Lowercase hex-encode the digest.
5. Compare to the header value after `sha256=` using a constant-time comparison.
6. Reject missing signatures, stale timestamps, duplicate event IDs, and malformed payloads.
7. Return a 2xx response only after the event is durably accepted.

### Delivery Attempts And Audit

Delivery runs as a Hangfire background job. Any non-2xx response fails that delivery attempt. OptiTrust records the failure and rethrows it to Hangfire. Any retries are platform-managed; there is no guaranteed attempt count, backoff schedule, or delivery SLA.

Receivers must make duplicate deliveries harmless by using `X-OptiTrust-Event-Id` as the idempotency key. Bundle responses expose up to the 10 most recent webhook delivery entries, with `dateUtc`, `status` (`delivered` or `failed`), and `detail`.

Webhook payload shape:

```json
{
  "eventId": "8f55f15d0c1f4b4c96f403edb89bb720",
  "event": "bundle.file_added",
  "occurredAtUtc": "2026-07-10T08:30:00Z",
  "bundle": {
    "identifier": "c73c4e02-85c5-4b1b-9ad0-9af82b46ec22",
    "status": "Awaiting documents",
    "dateAddedUtc": "2026-07-10T08:00:00Z",
    "deleted": false,
    "fileCount": 2
  },
  "file": {
    "id": 42,
    "fileName": "bank-statement.pdf",
    "status": "Pending",
    "result": {
      "processingState": "pending",
      "code": "ToBeProcessed",
      "label": "To be processed",
      "verdict": "pending",
      "riskBand": "not_applicable",
      "riskScore": null,
      "issues": [],
      "manuallyApproved": false,
      "flaggedForReview": false
    },
    "deleted": false,
    "deletedAtUtc": null
  }
}
```

The `file` object is `null` for bundle-level events.

## Employment Confirmation Workflow

Use this workflow when the client wants OptiTrust to confirm an employee's employment details.

Submit:

```http
POST {baseUrl}/api/employment-confirmations
XApiKey: {apiKey}
Content-Type: application/json
```

```json
{
  "tenantClientReference": "client-ref-123",
  "clientIdNumber": "8001015009087",
  "clientFullNames": "Example Person",
  "clientPhone": "+27110000000",
  "consentRecorded": true,
  "employerName": "Example Employer",
  "employerDomain": "example.co.za",
  "employerBranchName": "Main Branch",
  "employerBranchCode": "001",
  "employerBranchAddress": "1 Example Street",
  "employerHrEmail": "hr@example.co.za",
  "employerHrContactName": "HR Team",
  "employerHrPhone": "+27110000001"
}
```

Required fields:

- `tenantClientReference`
- `clientIdNumber`
- `clientFullNames`
- `employerName`

Set `consentRecorded` to `true` only when the client has recorded consent. Tenant and audit fields come from the API key and must not be sent.

Response: `202 Accepted` with a message. To track progress, list or fetch requests:

```http
GET {baseUrl}/api/employment-confirmations?status=Pending&page=1
XApiKey: {apiKey}
```

```http
GET {baseUrl}/api/employment-confirmations/{id}
XApiKey: {apiKey}
```

Employment confirmation status values:

- `Pending`
- `Confirmed`
- `Rejected`
- `Cancelled`

## PDF Certificate Sealing Workflow

Use `/api/sign` to synchronously apply a certificate-backed seal to one PDF and receive the sealed PDF bytes. No recipient action is involved.

```http
POST {baseUrl}/api/sign
XApiKey: {apiKey}
Content-Type: multipart/form-data
```

Multipart fields:

- `file`: required PDF file, max 10 MB
- `reason`: optional visible signature reason
- `placementOrigin`: optional, one of `firstPage`, `lastPage`, `newPage`, `page`, `anchorText`
- `placementPage`: required when `placementOrigin=page`; 1-based page number
- `placementX`: required when `placementOrigin=page`; fraction from 0 to 1
- `placementY`: required when `placementOrigin=page`; fraction from 0 to 1, measured from the page bottom
- `anchorText`: required when `placementOrigin=anchorText`; 1 to 128 characters, no control characters

Response: `200 OK` with `application/pdf` bytes.

Do not use this endpoint for statement bundle file upload. It is only for PDF certificate sealing.

## Signature Request Workflow

Use `/api/signing-requests` when the client supplies a PDF, OptiTrust must collect one or more
recipient signatures, and the completed sealed document must be returned to the client.

Create the request as multipart form data. The `request` part is a JSON string. For a single
document, the `file` part is the original PDF and the existing top-level `documentName`, `category`,
and `fields` properties describe it. For an envelope, repeat the `files` part in document order and
provide a matching `documents` metadata array. Each PDF is limited to 10 MB; the envelope is limited
to five documents, 25 MB, 100 pages, and 250 fields. A unique `Idempotency-Key` header is required.

```http
POST {baseUrl}/api/signing-requests
XApiKey: {apiKey}
Idempotency-Key: loan-48392-signing-v1
Content-Type: multipart/form-data
```

Example `request` JSON using both supported placement modes:

```json
{
  "documentName": "Loan agreement 48392",
  "clientReference": "loan-48392",
  "category": "CreditAgreement",
  "electronicSignatureAcknowledged": true,
  "requireOtp": true,
  "expiryDays": 14,
  "routingMode": "sequential",
  "callbackUrl": "https://client.example.com/optitrust/signing-events",
  "recipients": [
    { "role": 1, "name": "Primary Applicant", "email": "applicant@example.com" },
    { "role": 2, "name": "Witness", "email": "witness@example.com" }
  ],
  "fields": [
    {
      "type": "Signature",
      "signerRole": 1,
      "required": true,
      "label": "Applicant signature",
      "placement": {
        "mode": "anchor",
        "anchorText": "[[APPLICANT_SIGNATURE]]",
        "offsetX": 0,
        "offsetY": 0,
        "width": 0.28,
        "height": 0.07
      }
    },
    {
      "type": "Signature",
      "signerRole": 2,
      "required": true,
      "label": "Witness signature",
      "placement": {
        "mode": "coordinates",
        "page": 5,
        "x": 0.55,
        "y": 0.12,
        "width": 0.28,
        "height": 0.07
      }
    }
  ]
}
```

Multi-document requests keep recipients and delivery settings at envelope level:

```json
{
  "documentName": "Loan pack 48392",
  "clientReference": "loan-48392",
  "electronicSignatureAcknowledged": true,
  "routingMode": "sequential",
  "recipients": [
    { "role": 1, "type": "signer", "name": "Primary Applicant", "email": "applicant@example.com" }
  ],
  "documents": [
    {
      "documentName": "Loan agreement",
      "category": "CreditAgreement",
      "fields": [
        {
          "type": "Signature", "signerRole": 1, "required": true, "label": "Applicant signature",
          "placement": { "mode": "coordinates", "page": 5, "x": 0.55, "y": 0.12, "width": 0.28, "height": 0.07 }
        }
      ]
    },
    {
      "documentName": "Debit order authority",
      "category": "GeneralCommercial",
      "fields": [
        {
          "type": "Initials", "signerRole": 1, "required": true, "label": "Applicant initials",
          "placement": { "mode": "anchor", "anchorText": "[[APPLICANT_INITIALS]]", "width": 0.16, "height": 0.06 }
        }
      ]
    }
  ]
}
```

The first `files` part corresponds to `documents[0]`, the second to `documents[1]`, and so on.
Placement page numbers and anchors are resolved within their own PDF. Status responses include a
`documents` array with each document's category, page range, and field count.

Placement rules:

- Coordinates are normalized fractions from 0 to 1. `x` is measured from the left and `y` from
  the bottom of the effective page. `page` is 1-based.
- An anchor must be searchable standalone PDF text, appear exactly once in the entire document,
  and be unique within the request. Scanned PDFs require OCR before anchor placement can work.
- `offsetX` and `offsetY` are normalized page fractions applied from the located anchor.
- The requested box must remain entirely inside an unrotated, unscaled page and be at least 8pt
  on each side. OptiTrust paints the anchored box white so the marker is absent from the signed PDF.
- Field types are `Text`, `Date`, `Checkbox`, `Signature`, `Initials`, and `Radio`. Each recipient
  role must own at least one field.
- Categories are `GeneralCommercial`, `EmploymentDocument`, `CreditAgreement`,
  `ShortRentalOrLeaseUnder20Years`, and `OtherAllowedCommercial`.
- Routing is `sequential` or `parallel`; recipient roles are unique and contiguous from 1.

The create response is `202 Accepted`. Store its public `id`, `statusUrl`, `documentUrl`, and the
one-time returned `webhookSecret`. Repeating the exact request with the same `Idempotency-Key`
returns the existing request; reusing the key with different PDF bytes or request JSON returns
`409 Conflict`.

Poll status with:

```http
GET {baseUrl}/api/signing-requests/{id}
XApiKey: {apiKey}
```

Void a draft or active request when it must no longer be completed:

```http
POST {baseUrl}/api/signing-requests/{id}/void
XApiKey: {apiKey}
Content-Type: application/json

{ "reason": "noLongerRequired" }
```

Supported reasons are `createdInError`, `recipientDetailsIncorrect`, `replacedByNewRequest`,
`noLongerRequired`, and `other`. A successful response has `status=voided`, `voidedAtUtc`, and
`voidReason`. Voiding immediately invalidates every active recipient link and returns `409` if the
request is already completed, declined, expired, or voided.

After `status=completed` and `sealStatus=sealed`, download the sealed PDF with:

```http
GET {baseUrl}/api/signing-requests/{id}/document
XApiKey: {apiKey}
```

The document endpoint returns `404` until the seal exists, after erasure, or for another tenant.
It sends `Cache-Control: no-store` and always requires the API key.

Signature-request events are `document.sealed`, `document.declined`, `document.expired`, and
`document.voided`. Webhooks use
the same four headers documented above. Verify their signature over this exact UTF-8 string:
`{timestamp}.{eventId}.{rawJsonBody}`, using the signing request's `webhookSecret`. The sealed event
includes `documentUrl`; fetch the PDF with the API key rather than expecting binary data in the
webhook. Treat `X-OptiTrust-Event-Id` as the delivery idempotency key.

## Error Handling Rules

- `401`: API key is missing, invalid, revoked, or tenant signing is not authorized.
- `400`: validation failure, unsupported file type, invalid upload token, invalid placement, insufficient bundle credits, or missing required fields.
- `404`: requested resource was not found for this tenant, or a signed document is not yet available.
- `409`: an `Idempotency-Key` was reused with different signing-request content, or a signature
  request could not be voided because it had already reached a terminal state.
- `429`: the in-process global application limiter rejected the request. It also applies to authenticated API calls and is not a dedicated API quota.
- `500`: server-side failure. Retry only when the operation is idempotent or when the client can safely detect duplicates.

For webhook receivers, store `X-OptiTrust-Event-Id` before side effects or in the same transaction as the side effect. Duplicate deliveries must be harmless.

## Implementation Checklist For An LLM Agent

When generating client code, do all of the following:

1. Create a small typed API client with configurable `baseUrl`, `apiKey`, timeout, and a client-side retry policy only for safely repeatable API operations.
2. Attach `XApiKey` exactly; do not use `Authorization: Bearer` unless a separate proxy requires it.
3. Implement the bundle upload as three calls: upload-target, Azure Blob `PUT`, completion JSON.
4. Validate local files before upload: PDF only, non-empty, maximum 10 MB.
5. Persist bundle `identifier`, file `id`, webhook event IDs, and employment confirmation `tenantClientReference`.
   For signature requests, also persist the request `id`, `clientReference`, and `webhookSecret`.
6. Add webhook signature verification using raw body bytes and constant-time comparison.
7. Keep all secrets and signed URLs out of logs.
8. Use Swagger at `/swagger/v1/swagger.json` to generate DTOs or confirm field names.
9. Add integration tests using a fake webhook receiver and a synthetic, non-sensitive PDF.
10. Document operational retries and failure handling for the client.

## Common Mistakes To Avoid

- Do not send `X-Api-Key`; the header is `XApiKey`.
- Do not upload PDF bytes to `POST /api/bundles/{identifier}/files`; use the signed `uploadUri`.
- Do not choose your own `fileId`; use the server-issued `fileId`.
- Do not reuse an `uploadUri` or `uploadToken` for multiple files.
- Do not assume a `202 Accepted` employment confirmation response means the confirmation is complete.
- Do not parse webhook JSON before preserving the exact raw body needed for HMAC verification.
- Do not treat display names like `Awaiting documents` as enum input values.
- Do not expose `webhookSecret`, `apiKey`, `uploadToken`, or `uploadUri` to browsers unless the browser is the intended trusted upload client.
- Do not reuse a signing-request `Idempotency-Key` for different PDF bytes, recipients, or fields.
- Do not assume an anchor can be found in an image-only PDF or allow the same marker to appear twice.

## Minimum Smoke Test

A useful end-to-end smoke test should:

1. Create a bundle with an HTTPS webhook receiver.
2. Request an upload target.
3. Upload a small valid PDF to `uploadUri`.
4. Complete the file upload with the returned `fileId` and `uploadToken`.
5. Confirm the bundle response includes the file.
6. Receive `bundle.created` and `bundle.file_added` webhooks.
7. Verify webhook HMAC signatures and idempotency handling.
8. Delete the bundle or test data when finished.
