# List audit logs
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/audit-logs/list-audit-logs
/openapi-2025-01-29.alpha.json get /audit-logs
Returns audit log entries for your organization within an optional date range. Supports cursor-based pagination (`limit`, `after`, `before`) and optional filters on actors and categories.
# Create clause
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/clauses/create-clause
/openapi-2025-01-29.alpha.json post /clauses
Creates a Clause Library entry from name, description, and contentText (plain text or markdown). contentText is converted to Slate server-side (headings, marks, and lists preserved). Optional language defaults to the organization language.
# Get clause
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/clauses/get-clause
/openapi-2025-01-29.alpha.json get /clauses/{id}
Retrieves a single clause with Slate content, plain-text contentText, and per-language translations.
# List clauses
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/clauses/list-clauses
/openapi-2025-01-29.alpha.json get /clauses
Returns a paginated list of clause summaries (id, name, description, languages, timestamps). Does not include Slate content or contentText — use GET /clauses/:id for the full clause body.
# Get contract type template content
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contract-types/get-contract-type-template-content
/openapi-2025-01-29.alpha.json get /contract-types/{templateId}/content
Returns the document body of a template: Slate `content` and plain-text `contentText`. Scoped to templates in your organization.
# Get contract type templates
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contract-types/get-contract-type-templates
/openapi-2025-01-29.alpha.json get /contract-types/{id}/templates
Returns the templates of a specific contract type with their full smart-fields (type, document and condition attributes). Does not include template body — use GET /contract-types/:templateId/content for content.
# Get contract types creation form
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contract-types/get-contract-types-creation-form
/openapi-2025-01-29.alpha.json get /contract-types/{id}/creation-form
Returns the smart-fields and json-rules-engine rules used when creating a contract for this type, optionally scoped to a template.
# List contract types
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contract-types/list-contract-types
/openapi-2025-01-29.alpha.json get /contract-types
Returns a paginated list of contract types in your organization with their type-level smart-fields and whether each type has templates. Supports filtering by published status.
# Add a contract approver
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/add-a-contract-approver
/openapi-2025-01-29.alpha.json post /contracts/{id}/approvers
Adds one organization member, identified by email, to the approvers of the contract. Call it once per approver: each call adds to the list and never replaces it. The member must be an admin or a manager. The approvers apply to the next send only, for review or for signature: add them again to get an approval at a later send. Accepted on draft or negotiating contracts whose contract type does not apply its own approval workflow before review. Returns the approvers of the contract after the change. See [How contract approvers work](/api-reference/contract-approvers).
# Cancel contract signatures
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/cancel-contract-signatures
/openapi-2025-01-29.alpha.json post /contracts/{id}/cancel-signatures
Cancels the signature process of a contract in signing, as the "Cancel signature" action in the app does: the contract goes back to negotiating and participants are notified. Call it when the signature will not finish in the third-party tool (canceled, refused or expired).
# Create contract
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/create-contract
/openapi-2025-01-29.alpha.json post /contracts
Creates a contract with document from a template.
# Get consolidated contract
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/get-consolidated-contract
/openapi-2025-01-29.alpha.json get /contracts/{id}/consolidated
Same shape as Get contract, but `fields` values prefer amendment consolidation tips when present. Use Get contract for as-signed values only.
# Get contract
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/get-contract
/openapi-2025-01-29.alpha.json get /contracts/{id}
Get a contract
# Get contract clauses
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/get-contract-clauses
/openapi-2025-01-29.alpha.json get /contracts/{id}/clauses
Returns all clauses attached to a specific contract.
# Get contract current version
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/get-contract-current-version
/openapi-2025-01-29.alpha.json get /contracts/{id}/current-version
Returns the current working document of the contract with a short-lived pre-signed download URL and file metadata. The format (PDF or DOCX) is chosen by the server based on the document content. URLs expire after 30 seconds; re-call the endpoint to obtain a fresh one.
# Get contract signed files
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/get-contract-signed-files
/openapi-2025-01-29.alpha.json get /contracts/{id}/signed-files
Returns every signed file attached to the contract’s signature, each with a short-lived pre-signed download URL and file metadata. Clients pick the file(s) they want to download. URLs expire after 30 seconds; re-call the endpoint to obtain fresh ones.
# Get contract signing files
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/get-contract-signing-files
/openapi-2025-01-29.alpha.json get /contracts/{id}/signing-files
Returns everything a signature tool needs to run the signing ceremony for a contract that was sent for signature: the files to sign with short-lived pre-signed download URLs and page dimensions, the position of every signature and initials field on them, and the signatories. Typically called after receiving the `signature.requested` webhook. URLs expire after 5 minutes; re-call the endpoint to obtain fresh ones.
# List contract approvers
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/list-contract-approvers
/openapi-2025-01-29.alpha.json get /contracts/{id}/approvers
Returns the approvers added on the contract, as in the "Approvers" section of the contract in the app. They apply to the next send of the contract, for review or for signature, and the list is emptied once that approval starts. `customApproversAllowed` is false when approvers cannot be added: the contract is in signing, signed or canceled, or its contract type applies its own approval workflow before review. See [How contract approvers work](/api-reference/contract-approvers).
# List contracts
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/list-contracts
/openapi-2025-01-29.alpha.json get /contracts
Returns a paginated list of contracts in your organization. Supports cursor-based pagination, sorting, filtering, and optional text search on contract and counterparty names.
# Remove a contract approver
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/remove-a-contract-approver
/openapi-2025-01-29.alpha.json delete /contracts/{id}/approvers
Removes an approver, identified by email, from the contract, as long as the next send has not happened yet. Returns the approvers of the contract after the change. See [How contract approvers work](/api-reference/contract-approvers).
# Request contract signatures
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/request-contract-signatures
/openapi-2025-01-29.alpha.json post /contracts/{id}/request-signatures
Accepts a contract document for signature and starts the signature process if possible.
# Update contract
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/update-contract
/openapi-2025-01-29.alpha.json patch /contracts/{id}
Updates a contract with the provided fields. Only the fields provided in the request body will be updated.
# Upload contract attachment
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/upload-contract-attachment
/openapi-2025-01-29.alpha.json post /contracts/{id}/attachments
Attaches a previously uploaded file (PDF, Word, Excel…) to the contract, outside its main document — e.g. the signature certificate issued by a third-party signature tool. First call `POST /files/upload-url` to obtain a pre-signed URL, `PUT` the file body to S3, then call this endpoint with the returned `key`.
# Upload contract new version
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/upload-contract-new-version
/openapi-2025-01-29.alpha.json post /contracts/{id}/versions
Creates a new contract version on the contract from a previously uploaded file. First call `POST /files/upload-url` to obtain a pre-signed URL, `PUT` the file body to S3, then call this endpoint with the returned `key`.
# Upload contract signed version
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/contracts/upload-contract-signed-version
/openapi-2025-01-29.alpha.json post /contracts/{id}/signed-version
Marks the contract as signed from PDFs signed in a third-party tool: attaches them as the signed files, moves the contract to signed and stamps `signatureDate`, which also sets the start date when the contract type derives it from the signature date. Accepted from draft, negotiating, or signing when the signature was sent through a third-party tool. First call `POST /files/upload-url` once per PDF, `PUT` each file body to the returned URL, then call this endpoint with the returned keys.
# Create counterparty
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/counterparties/create-counterparty
/openapi-2025-01-29.alpha.json post /counterparties
Creates a new counterparty in your organization.
# Get counterparty
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/counterparties/get-counterparty
/openapi-2025-01-29.alpha.json get /counterparties/{id}
Retrieves detailed information about a specific counterparty by their unique identifier.
# List counterparties
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/counterparties/list-counterparties
/openapi-2025-01-29.alpha.json get /counterparties
Returns a paginated list of counterparties in your organization. Supports cursor-based pagination, sorting, and filtering.
# Update counterparty
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/counterparties/update-counterparty
/openapi-2025-01-29.alpha.json patch /counterparties/{id}
Updates a counterparty with the provided fields. Only the fields provided in the request body will be updated.
# Get custom object
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/custom-objects/get-custom-object
/openapi-2025-01-29.alpha.json get /custom-objects/{id}
Returns a custom object with its associated smart fields.
# List contract records
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/custom-objects/list-contract-records
/openapi-2025-01-29.alpha.json get /custom-objects/{customObjectId}/contract/{contractId}/records
Returns the records attached to a contract for a given custom object.
A `relation` field stores the identifier of a record living in another custom object, so its `rawValue` and `resolvedValue` are both that identifier. The linked record and its readable field values are returned inline under `relatedRecords`, so no extra call is needed to display a label.
# List custom objects
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/custom-objects/list-custom-objects
/openapi-2025-01-29.alpha.json get /custom-objects
Returns the list of custom objects in your organization.
# Upsert contract records
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/custom-objects/upsert-contract-records
/openapi-2025-01-29.alpha.json put /custom-objects/{customObjectId}/contract/{contractId}/records
Creates or updates contract records for a given custom object. Requires contract membership.
# Create file upload signed URL
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/files/create-file-upload-signed-url
/openapi-2025-01-29.alpha.json post /files/upload-url
Returns a short-lived pre-signed S3 URL the client must `PUT` the file body to. Once the upload is complete, pass the returned `key` to the endpoint that consumes it (e.g. `POST /contracts/{id}/versions`, `POST /contracts/{id}/signed-version` or `POST /contracts/{id}/attachments`).
# Create member
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/members/create-member
/openapi-2025-01-29.alpha.json post /members
Creates a new member in your organization. When `sendInvitation` is `true`, the new member will receive an email invitation to join your organization. The member status will be `pending` until they accept the invitation.
# Disable member out-of-office
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/members/disable-member-out-of-office
/openapi-2025-01-29.alpha.json delete /members/{id}/out-of-office
Disables the out-of-office period for the authenticated member. The path `id` must match the member that owns the API key.
# Get member
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/members/get-member
/openapi-2025-01-29.alpha.json get /members/{id}
Retrieves detailed information about a specific member by their unique identifier.
# Get member out-of-office
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/members/get-member-out-of-office
/openapi-2025-01-29.alpha.json get /members/{id}/out-of-office
Returns the out-of-office status and period for a specific member.
# List members
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/members/list-members
/openapi-2025-01-29.alpha.json get /members
Returns a paginated list of members in your organization. Supports cursor-based pagination, sorting, and filtering.
# Set member out-of-office
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/members/set-member-out-of-office
/openapi-2025-01-29.alpha.json put /members/{id}/out-of-office
Creates or updates the out-of-office period for the authenticated member. The path `id` must match the member that owns the API key.
# Update member
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/members/update-member
/openapi-2025-01-29.alpha.json patch /members/{id}
Updates an existing member's role, group assignments, or other properties. All fields are optional — only include the fields you want to change.
Moving a member to a lower role (VIEWER < USER < MANAGER < ADMIN) requires a `substituteId`: another member who takes over what the new role cannot hold, such as pending approvals and signatures. Without a valid substitute, the request answers 400 `InvalidRoleSubstitute`.
# Get organization
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/organization/get-organization
/openapi-2025-01-29.alpha.json get /organization
Returns the organization associated with the API key.
# List options for a select/multiselect smart field
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/smart-fields/list-options-for-a-selectmultiselect-smart-field
/openapi-2025-01-29.alpha.json get /smart-fields/{id}/options
Returns a paginated list of options for a `select` or `multiselect` smart field. Supports cursor-based pagination and optional label search.
# List smart fields
Source: https://alpha.developer.tomorro.com/api-reference/2025-01-29-alpha/smart-fields/list-smart-fields
/openapi-2025-01-29.alpha.json get /smart-fields
Returns a paginated list of smart fields (attribute definitions) in your organization. Supports cursor-based pagination, sorting, and filtering.
# How contract approvers work
Source: https://alpha.developer.tomorro.com/api-reference/contract-approvers
When you can add approvers to a contract, which send they apply to, and how to get an approval before review and before signature
The `/contracts/{id}/approvers` endpoints manage the approvers **added on one contract**. These approvers must approve the contract before Tomorro sends it, either for review or for signature.
They are the same approvers as the **Approvers** section of a contract in the Tomorro app. They are separate from the approval workflows that an admin configures on a contract type.
## The rules in short
1. **You can add approvers only when the contract type does not already require an approval before review.** A contract type workflow always takes precedence over approvers added on a contract.
2. **The approvers apply to the next send only**, whether it is a send for review or a send for signature.
3. **The list is emptied as soon as that approval starts.** To get another approval at a later send, add the approvers again.
4. **Add approvers one email at a time.** Each `POST` adds one member to the list. It never replaces the list.
## Before you add approvers
Call `GET /contracts/{id}/approvers` and read `customApproversAllowed`.
| `customApproversAllowed` | Meaning |
| - | - |
| `true` | You can add approvers. |
| `false` | The contract type applies its own approval workflow before review, or the contract is in signing, signed or canceled. `POST` returns `400`. |
The contract type workflow blocks you only when it applies to this contract: a workflow with conditions (for example, an amount above a threshold) blocks you only when the contract meets those conditions.
> [!NOTE]
>
> If the contract has no document yet, Tomorro cannot evaluate the contract type workflow, so `customApproversAllowed` is `true`. When the contract is later sent for review, the contract type workflow applies if it exists, and the approvers you added are dropped without being asked.
## Add and remove approvers
* `POST /contracts/{id}/approvers` with `{ "email": "..." }` adds **one** member. Call it once per approver: three calls give three approvers. Adding an email that is already in the list does nothing.
* `DELETE /contracts/{id}/approvers` with `{ "email": "..." }` removes one member, as long as the next send has not happened yet.
* Both calls return the full list after the change.
The email identifies the member, so a name is never accepted. The member must be an **admin** or a **manager** of the organization.
> [!WARNING]
>
> The member who sends the contract cannot approve their own send. If they are in the list, Tomorro skips them. If no other approver is left, no approval is requested. Do not add the member who owns your API key if that member also sends the contract.
## Which send the approvers apply to
The approvers apply to the **next send** of the contract, then the list is emptied:
* **Send for review** (the document goes to the counterparty): the approvers must approve before Tomorro sends the document. Once they approve, Tomorro sends it automatically.
* **Send for signature**: the approvers must approve before Tomorro sends the contract for signature, unless the contract type has its own approval workflow before signature. In that case, the contract type workflow applies and the approvers you added are dropped without being asked.
```mermaid theme={null}
flowchart TD
A[Contract created] --> B{"GET /approvers
customApproversAllowed?"}
B -- false --> C["POST returns 400
Only the contract type workflow applies"]
B -- true --> D["POST /approvers
once per approver"]
D --> E{Next send}
E -- Send for review --> F["Your approvers approve
before the counterparty gets the document"]
E -- Send for signature --> G{"Contract type has a workflow
before signature?"}
G -- yes --> H["The contract type workflow applies
your list is dropped"]
G -- no --> I["Your approvers approve
before signature"]
F --> J["List emptied
add approvers again for the next send"]
H --> J
I --> J
```
## Get an approval before review and before signature
A list applies to one send only. To get an approval at both steps, add the approvers twice:
1. Right after the contract is created, add the approvers for the review.
2. Once the review has been sent, add the approvers again for the signature. You know the review was sent when the contract moves to `negotiating`, for example with the [`contractStatusUpdated` webhook](/webhooks/overview).
3. If your integration sends the contract for signature with `POST /contracts/{id}/request-signatures`, you can instead add the approvers right before that call.
To get an approval **only before signature**, skip step 1.
> [!NOTE]
>
> Webhooks are personal: a webhook only fires for events that concern the member who created it. Make sure that member is a participant of the contracts you follow.
## What the list does not show
`GET /contracts/{id}/approvers` returns the approvers waiting for the next send. It does not show:
* the approvers of a contract type workflow;
* an approval that is in progress, or who already approved. Once the approval starts, the list is empty.
# API Reference
Source: https://alpha.developer.tomorro.com/api-reference/introduction
Complete reference for the Tomorro Public API
## Base URL
All API requests should be made to:
```
https://api.tomorro.com/v2
```
## Authentication
The Tomorro API uses API keys for authentication. Include your API key in the `x-api-key` header with every request.
```bash theme={null}
curl -X GET "https://api.tomorro.com/v2/contracts" \
-H "x-api-key: your-api-key"
```
> **[Get your API key](https://app.tomorro.com/settings/integrations?integration=api-key)**
>
> Generate an API key from your organization settings.
> [!WARNING]
>
> Keep your API key secure, do not share it in publicly accessible areas such as GitHub, client-side code, or public
> repositories.
## Terminology
| API name | Tomorro name |
| - | - |
| `type` | Contract type |
| `template` | Template |
| `contract` | Contract |
| `smart-field` | Smart field |
| `counterparty` | Counterparty |
| `member` | Member |
| `clauses` | Clauses |
| `custom object` | Dynamic tables |
## Response Format
All responses are returned in JSON format. Successful responses wrap the data in a `data` field:
```json theme={null}
{
"data": {
"id": "ctr_550e8400-e29b-41d4-a716-446655440000",
"name": "Service Agreement",
"status": "draft",
...
}
}
```
## Pagination
List endpoints use cursor-based pagination for efficient traversal of large datasets.
### Parameters
| Parameter | Type | Default | Description |
| - | - | - | - |
| `limit` | integer | 20 | Number of items per page (1-100) |
| `after` | string | - | Cursor for forward pagination |
| `before` | string | - | Cursor for backward pagination |
### Response
Paginated responses include a `pagination` object:
```json theme={null}
{
"data": [...],
"pagination": {
"hasNext": true,
"hasPrevious": false,
"next": "eyJpZCI6IjEyMyJ9",
"previous": null
}
}
```
### Example: Fetching pages
```bash theme={null}
# First page
curl "https://api.tomorro.com/v2/contracts?limit=20" \
-H "x-api-key: your-api-key"
# Next page (using cursor from previous response)
curl "https://api.tomorro.com/v2/contracts?limit=20&after=eyJpZCI6IjEyMyJ9" \
-H "x-api-key: your-api-key"
```
## Sorting
Use the `sort` parameter to order results. Prefix with `-` for descending order.
```bash theme={null}
# Sort by creation date (newest first)
GET /contracts?sort=-createdAt
# Sort by name ascending
GET /contracts?sort=name
```
## Filtering
The API uses a simplified filtering format. Use query parameters directly.
### Filter Operators
| Format | Description | Example |
| - | - | - |
| `field=value` | Equality (default) | `status=active` |
| `field=in:value1,value2` | In list (OR condition) | `status=in:draft,negotiating` |
| `field=gte:value` | Greater than or equal | `startAt=gte:2024-01-01` |
| `field=lte:value` | Less than or equal | `endAt=lte:2024-12-31` |
| `field=ne:value` | Not equals | `status=ne:canceled` |
On `GET /contracts`, `contractTypeId` applies to **one** contract type UUID per request. Using `in:` with several UUIDs (for example `contractTypeId=in:uuid1,uuid2`) is not supported for multiple types; make one request per contract type and merge the results client-side.
### Examples
```bash theme={null}
# Simple equality
GET /contracts?status=signed
# Filter multiple values (OR condition)
GET /contracts?status=in:draft,negotiating
# Date range
GET /contracts?startAt=gte:2024-01-01&endAt=lte:2024-12-31
# Multiple filters (AND condition)
GET /contracts?status=signed&counterpartyId=cp_123
# Search by name
GET /counterparties?name=Acme
```
## Error Handling
The API uses standard HTTP status codes to indicate success or failure.
### Status Codes
| Code | Description |
| - | - |
| `200` | Success |
| `201` | Resource created |
| `204` | Success (no content) |
| `400` | Bad request - Invalid parameters |
| `401` | Unauthorized - Invalid or missing API key |
| `404` | Not found - Resource doesn't exist |
| `409` | Conflict - Resource state conflict |
| `429` | Too many requests - Rate limit exceeded |
| `500` | Internal server error |
### Error Response Format
```json theme={null}
{
"error": {
"statusCode": "NOT_FOUND",
"errorId": "FIELD_NOT_FOUND",
"message": "Field 'InvalidField' not found",
"details": {
"field": "InvalidField",
"providedValue": "some value",
"availableOptions": ["Industry", "Company Size", "Tags"]
}
}
}
```
### Common Error Codes
| Code | Description |
| - | - |
| `CONTRACT_NOT_FOUND` | Contract does not exist |
| `COUNTERPARTY_NOT_FOUND` | Counterparty does not exist |
| `TEMPLATE_NOT_FOUND` | Template does not exist |
| `MEMBER_NOT_FOUND` | Member does not exist |
| `FIELD_NOT_FOUND` | Unknown field name or ID |
| `FIELD_OPTION_NOT_FOUND` | Unknown option for select field |
| `INVALID_STATUS_TRANSITION` | Cannot transition to requested status |
## Rate Limiting
The API implements rate limiting to ensure fair usage. If you exceed the rate limit, you'll receive a `429 Too Many Requests` response.
> [!TIP]
>
> Implement exponential backoff in your client to handle rate limiting gracefully.
## Simplified Workflows
The v2 API significantly reduces the number of API calls needed for common operations.
### Creating a Contract (Before vs After)
**Before (GraphQL - 2+ calls):**
```
1. createContract({ name, externalCompany, typeId })
2. createDocument({ contractId, templateId })
```
**After (REST - 1 call):**
```bash theme={null}
POST /contracts
{
"templateId": "tpl_...",
"counterpartyId": "cp_...",
"fields": { "Industry": "Technology" }
}
```
### Updating a Field (Before vs After)
**Before (GraphQL - 3+ calls):**
```
1. Query attributeDefinitions → get ID by name
2. Query attributeDefinition → get option ID
3. updateExternalCompanyAttributes({ attributeDefinitionId, value: optionId })
```
**After (REST - 1 call):**
```bash theme={null}
PATCH /counterparties/:id
{ "fields": { "Industry": "Technology" } }
```
### Sending for Signature (Before vs After)
**Before (GraphQL - 4-6 calls):**
```
1. createContract
2. createDocument
3. assignSignatoryUser (per signatory)
4. prepareDocumentForSignature
5. acceptForSignature
```
**After (REST - 2 calls):**
```bash theme={null}
# 1. Create contract with document
POST /contracts
# 2. Send for signature
POST /contracts/:id/signatures/send
```
# Non-Technical scoping
Source: https://alpha.developer.tomorro.com/guides/backup-your-signed-contracts/non-technical-scoping
Back up your signed contracts: what the business owner decides, and the brief to hand to IT
**Goal:** every contract signed in Tomorro is also saved as a PDF in your own storage (SharePoint, Google Drive, Box, S3...), with no download by hand. Your teams keep a copy where they already work, and your IT keeps a backup it controls.
**TL;DR:** one flow, plus a one-off export, run by a no-code tool (Make, n8n, Zapier) or a few lines of code.
* **Flow 0: Initialisation.** Run once: every contract already signed in Tomorro is copied to your storage.
* **Flow 1: Contract signed.** As soon as a contract is signed in Tomorro, its signed PDF lands in your storage.
Two ways to organise the backup:
* **Simple (recommended):** every signed contract lands in **one folder**, with a clear file name and, if your storage supports it, metadata columns (contract type, counterparty, signature date) to sort and filter.
* **Advanced:** the backup **mirrors your Tomorro folder tree**. Possible, but much more work to build and to maintain: Tomorro does not give the folder path of a contract, so IT has to rebuild your Tomorro storage rules in the integration.
**For the business owner.** A few decisions, no technical skills needed. Fill in the brief below and send it to your IT team or integrator: with the [Technical recipes](/guides/backup-your-signed-contracts/technical-recipes), it has everything they need.
## Brief for your IT team
Copy this brief, fill in the blanks, and send it to your IT team or integrator. **It is all they need from you.**
```markdown Brief for IT theme={null}
Subject: Tomorro signed contracts backup — ready to build
Hi,
I have done the business part of the Tomorro signed contracts backup. Over to you:
everything you need to build it is below.
1. Scope
- Storage: ______________________ (e.g. SharePoint, site "Legal", library "Contracts")
- Contracts to back up: ______________________ (e.g. every signed contract / only some contract types)
- Contracts already signed (Flow 0): yes / no
- Organisation: simple (one folder) / advanced (mirror the Tomorro folders)
2. File name and metadata
- File name: ______________________
- Metadata columns: ______________________ -> ______________________
- ______________________ -> ______________________
- ______________________ -> ______________________
3. Timing
- Flow 0 - Initialisation: ______________________ (e.g. once, before go-live)
- Flow 1 - Contract signed: ______________________ (e.g. as soon as the contract is signed)
4. Done on my side
- [ ] Destination folder created, write access given to the integration account
- [ ] Advanced only: list of our Tomorro storage rules, per contract type
Your part (all in the technical recipes):
- Technical recipes: https://alpha.developer.tomorro.com/guides/backup-your-signed-contracts/technical-recipes
- API key and webhook: Tomorro > Settings > Integrations, from a dedicated superadmin account
Thanks!
```
## Help to complete the brief
### 1 - Scope
| Question | Example answer | Why it matters |
| - | - | - |
| Where should the copies go? | SharePoint, site "Legal", library "Contracts" | IT needs access to it |
| Which contracts? | Every signed contract | Or only some contract types: the integration skips the others |
| Back up the contracts already signed? | Yes | If not, Flow 0 is not needed |
| One folder, or the Tomorro folder tree? | One folder, with metadata columns | The tree is much harder to build and to keep in sync (see below) |
> **Why mirroring the Tomorro folders is harder**
>
> In Tomorro, the folder of a contract comes from **storage rules** on each contract type (e.g. "Contract type = NDA and counterparty country = France → Legal / NDA / France"). The API does not return a folder path for a contract, so the integration has to apply the same rules itself, from the contract's data.
>
> Consequences: every rule has to be listed and rebuilt in the integration, and every time a rule changes in Tomorro, the integration has to change too.
>
> Most storage tools (SharePoint, Google Drive) give a better result with **one folder and metadata columns**: sort, filter and save views by contract type, counterparty or date, with nothing to maintain.
### 2 - File name and metadata
How each signed PDF is named, and which contract data goes along with it.
> [!NOTE]
>
> Example only: choose the name and the columns that fit your storage.
| Tomorro data | In the storage | Rule |
| - | - | - |
| Contract name + contract id | File name | e.g. `NDA - Acme - 7d12db6f.pdf`: unique, even for two contracts with the same name |
| Contract type | Column "Contract type" | Copied as is |
| Counterparty | Column "Counterparty" | Copied as is |
| Signature date | Column "Signature date" | Copied as is (contracts signed after go-live) |
| Link to the contract | Column "Tomorro link" | Opens the contract in Tomorro |
### 3 - Timing
When should each automation run, and what starts it?
> [!NOTE]
>
> Example only: choose the timing that fits your process.
| Flow | What starts it | When it runs |
| - | - | - |
| Flow 0 - Initialisation | Run by hand | Once, before go-live |
| Flow 1 - Contract signed | Every contract signed in Tomorro | As soon as the contract is signed |
### 4 - Checklist
* [ ] Scope (1), file name and metadata (2) and timing (3) validated
* [ ] Destination folder (and metadata columns) created in the storage, write access for the integration
* [ ] Advanced only: the storage rules of each contract type listed (the storage settings of each contract type, see [Configure contract storage](https://help.tomorro.com/en/articles/13459997-configure-contract-storage) in the help center)
* [ ] An IT contact or integrator to build the flows, and the brief sent to them
# Technical recipes
Source: https://alpha.developer.tomorro.com/guides/backup-your-signed-contracts/technical-recipes
Back up your signed contracts: prerequisites, the flows and the API reference for your IT team
Everything runs on one Tomorro webhook and two REST endpoints. How you write to the storage depends on your storage (SharePoint, Google Drive, Box, S3...).
```mermaid theme={null}
sequenceDiagram
autonumber
actor U as Tomorro user
participant T as Tomorro
participant I as Integration
customer middleware
participant S as Storage
SharePoint, Drive, S3...
Note over U,S: Prerequisites: "automation" superadmin account, API key, contractSigned webhook created from that account, write access to the storage
rect rgb(232, 244, 253)
Note over U,S: Flow 0: Initialisation, run once by hand (Tomorro -> Storage)
loop Each page of signed contracts
I->>T: GET /v2/contracts?status=signed&limit=50
T-->>I: contracts + pagination.next_cursor
loop Each contract not in the storage yet
I->>T: GET /v2/contracts/{id}/signed-files
T-->>I: downloadUrl (valid 30 s)
I->>S: Upload the signed PDF
end
end
end
rect rgb(232, 244, 253)
Note over U,S: Flow 1: Contract signed (Tomorro -> Storage)
U->>T: Last signatory signs
T->>I: POST webhook contractSigned
header Leeway-Signature, timeout 3 s
I-->>T: 200 OK
I->>T: GET /v2/contracts/{id}/signed-files
T-->>I: downloadUrl (valid 30 s)
opt Metadata or advanced folder tree
I->>T: GET /v2/contracts/{id}
T-->>I: contract type, counterparty, fields
Note over I: Advanced: apply the Tomorro storage rules to find the folder
end
I->>S: Upload the signed PDF (+ metadata)
end
```
## 1. Technical prerequisites
**1. 1.1 - Create Tomorro superadmin account**
An existing admin creates a dedicated **superadmin** account from **Settings > Members** (e.g. `automation@customer.com`), not a personal account.
* The API key and the webhook are bound to a **member**, not to the organization: they only see the contracts that member can see.
* A **superadmin** sees **every contract** of the workspace, in every folder. Its webhook fires for every signed contract and its API key can download every signed file, without the account being added as a participant.
* With an **admin** account, the webhook only fires for the contracts it takes part in (or that sit in no folder), and the backup misses the others. Use a superadmin.
* Recommended: no personal mailbox behind it, and exclude it from Tomorro notification settings.
**2. 1.2 - API key**
Generate the API key from that superadmin account. It does not expire. It is sent on every REST call in the `x-api-key` header: [Step 1: Get your API key](/quickstart)
**3. 1.3 - contractSigned webhook**
**Logged in as the superadmin account**, create a webhook with the trigger `contractSigned` and the URL of your integration: [Webhooks](/webhooks/overview). Keep its signing secret to verify every delivery ([Technical documentation](/webhooks/technical-documentation)).
**4. 1.4 - Storage**
Out of scope (depends on the storage). Create the destination folder (and the metadata columns, see [Non-Technical scoping](/guides/backup-your-signed-contracts/non-technical-scoping)), and give the integration write access to it.
## 2. Flows
### Flow 0 - \[Tomorro → Storage] Initialisation
**1. Step 0 - Triggered by hand**
Run once, when the integration goes live: the webhook of Flow 1 only fires for contracts signed **after** it is created. Re-running it is safe: contracts already in the storage are skipped.
**2. Step 1 - List the signed contracts**
```http theme={null}
GET https://api.tomorro.com/v2/contracts?status=signed&limit=50
x-api-key:
```
Response `200`:
```json theme={null}
{
"data": [
{
"id": "7d12db6f-36a6-4dc5-b022-66471ff5xxxc",
"name": "NDA - Acme",
"status": "signed",
"counterparty": { "id": "...", "name": "Acme" },
"contractType": { "id": "...", "name": "NDA" },
"contractUrl": "https://app.tomorro.com/acme/project/7d12db6f-36a6-4dc5-b022-66471ff5xxxc"
}
],
"pagination": { "limit": 50, "next_cursor": "eyJpZCI6...", "has_next": true, "has_previous": false }
}
```
> **Notes**
>
> * Up to 50 contracts per page: while `pagination.has_next` is `true`, call again with `after=`.
> * Only some contract types: add `contractTypeId=` (one type per call).
> * Skip a contract whose file is already in the storage (file name contains the contract id, see step 3).
**3. Steps 2 and 3 - Same as Flow 1**
For each contract: get the signed files, upload them (Flow 1, steps 1 and 3).
### Flow 1 - \[Tomorro → Storage] Contract signed
**Simple backup (recommended)**: every signed contract lands in one folder.
**1. Step 0 - Receive the contractSigned webhook**
Verify the `Leeway-Signature` header, answer `2xx` **within 3 seconds**, then do the work asynchronously ([Technical documentation](/webhooks/technical-documentation)).
```json theme={null}
{
"eventId": "b21213e3-8a9a-4e04-9bfc-c4e53f123xxx",
"webhookId": "2a76094c-1f2e-48c8-a47f-1add41234xxx",
"createdAt": "2026-10-02T14:55:16.280Z",
"eventType": "contractSigned",
"data": {
"contract": {
"id": "7d12db6f-36a6-4dc5-b022-66471ff5xxxc",
"name": "NDA - Acme",
"status": "signed",
"signatureDate": "2026-10-02T14:55:10.000Z",
"typeId": "...",
"externalCompany": { "id": "...", "name": "Acme" }
},
"signatories": [ { "id": "...", "name": "Jane Doe", "email": "jane.doe@acme.com" } ]
}
}
```
> **Notes**
>
> * Keep `data.contract.id`, and `name`, `signatureDate`, `typeId`, `externalCompany.name` for the file name and the metadata.
> * Only some contract types: skip the event when `data.contract.typeId` is not one of them.
> * **Deduplicate on `eventId`**: a delivery that fails is retried (up to 10 times, 5 minutes apart), so the same event can arrive more than once.
**2. Step 1 - Get the signed files**
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}/signed-files
x-api-key:
```
Response `200`:
```json theme={null}
{
"data": [
{
"id": "550e8400-...",
"filename": "NDA - Acme - signed.pdf",
"contentType": "application/pdf",
"downloadUrl": "https://s3...",
"expiresAt": "2026-10-02T14:55:46.000Z",
"contractUrl": "https://app.tomorro.com/acme/project/7d12db6f-..."
}
]
}
```
> **Notes**
>
> * One entry per signed document: the main document, then the annexes signed in the same bundle, in order.
> * `downloadUrl` is a pre-signed URL valid **30 seconds**, see `expiresAt`. Download straight away (no auth header) and do not store the URL. Re-call the endpoint for a fresh one.
> * `409` when the contract has no signed file yet; `404` for an unknown contract.
**3. Step 2 - Read the contract metadata (optional)**
Only needed for metadata the webhook does not carry (contract type name, smart fields), or for the advanced folder tree below.
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}
x-api-key:
```
Returns `contractType` (`id`, `name`), `counterparty`, `fields` (smart field values) and `contractUrl`.
**4. Step 3 - Upload to the storage**
Out of scope (depends on the storage). Upload each file to the destination folder.
* **File name**: include the contract id (e.g. `NDA - Acme - 7d12db6f.pdf`): two contracts can share a name, and a re-run overwrites instead of duplicating.
* **Metadata**: contract type, counterparty, signature date, `contractUrl`... as columns, if the storage supports them.
* **Several files**: keep the order of step 1, e.g. `NDA - Acme - 7d12db6f.pdf`, then `NDA - Acme - 7d12db6f - Annex 1.pdf`.
> [!TIP]
>
> **Storage unreachable**: retry with backoff (Make, n8n and Zapier have a retry-on-error setting), and log the failed uploads to a channel someone reads (email, Slack). Answering `2xx` to the webhook first means Tomorro does not retry for you.
**Advanced: mirror the Tomorro folder tree.** Same flow, the folder is computed in step 2 instead of being fixed.
> [!WARNING]
>
> Tomorro does **not** return the folder path of a contract. `GET /v2/contracts/{id}` gives a `folderId`, but no public endpoint turns it into a folder name or path. The folder comes from the **storage rules** of the contract type: the integration has to apply the same rules itself, and be updated every time a rule changes in Tomorro. Start with the simple backup unless you have a strong reason not to.
**1. Step 2.1 - List your storage rules**
Once, from the storage settings of each contract type in Tomorro ([Configure contract storage](https://help.tomorro.com/en/articles/13459997-configure-contract-storage)), write each rule as a condition on the contract's data and a target path. For example:
| Contract type | Condition | Target folder |
| - | - | - |
| NDA | Counterparty country = France | `Legal/NDA/France` |
| NDA | Any other | `Legal/NDA/International` |
| Employment contract | - | `HR/Employment contracts/` |
**2. Step 2.2 - Compute the folder**
Read the contract (step 2) and evaluate the rules of its `contractType.id` against `counterparty` and `fields` (smart field values) to get the target path. No rule matches: fall back to a single "To sort" folder rather than dropping the file.
**3. Step 2.3 - Create the folder if needed**
Out of scope (depends on the storage). Create each missing level of the path, then upload as in step 3.
## 3. Technical references
Reference material: conventions, the endpoints, the webhook and error handling. Read it once, then come back to it while implementing the flows above.
### 3.1 - API conventions
* **REST only.** The whole integration runs on the public REST API and one webhook.
* **Base URL**: `https://api.tomorro.com/v2`
* **Authentication**: `x-api-key: ` on every call. A missing or unknown key returns 401.
* **Versioning**: optional `tomorro-version` header, defaults to the latest version.
* **Rate limit**: a `429` means too many calls in a short time. Retry with backoff, especially during Flow 0.
* **Sandbox**: available on request. It is an empty sandbox, not a copy of your production workspace.
* **Response envelope**: every **successful** JSON response is wrapped in a `data` object (errors come as `{ error, meta }`, see 3.4).
* **Full reference**: [Tomorro API](/api-reference/introduction)
### 3.2 - Endpoints
| Endpoint | Use in this integration |
| - | - |
| `GET /v2/contracts?status=signed` | Flow 0: every signed contract. `limit` 1 to 50, cursor pagination (`after`), `contractTypeId` to narrow down. |
| `GET /v2/contracts/{id}/signed-files` | Flows 0 and 1: the signed PDFs, with a download URL valid 30 seconds. |
| `GET /v2/contracts/{id}` | Flow 1, optional: contract type, counterparty, smart field values, `folderId`. |
### 3.3 - The contractSigned webhook
* **When it fires**: when a contract becomes `signed`, whatever the signature tool: Tomorro signature, DocuSign, or a signed version pushed by a third-party signature tool.
* **When it does not**: contracts signed before the webhook was created, and contracts **imported directly as signed** ([bulk import](https://help.tomorro.com/en/articles/3727366-import-contracts-in-bulk), [store a signed document](https://help.tomorro.com/en/articles/10493914-store-a-signed-document)). Flow 0 covers both: re-run it after a bulk import.
* **Payload**: `data.contract` (`id`, `name`, `status`, `signatureDate`, `typeId`, `externalCompany`, `attributes`...) and `data.signatories`. No folder.
* **Delivery**: `2xx` within 3 seconds, otherwise retried up to 10 times, 5 minutes apart; after the 10th failure the webhook is **disabled** and has to be re-enabled in the settings. See [Delivery & retries](/webhooks/technical-documentation).
* **Security**: every delivery is signed (`Leeway-Signature` header). Verify it before calling the API.
### 3.4 - Error handling
Every endpoint follows the standard error format of the REST API.
**Error envelope**
```json theme={null}
{
"error": {
"statusCode": "409",
"errorId": "optional, present on some domain errors",
"message": "Human readable message"
},
"meta": {
"timestamp": "2026-10-02T10:09:22.634Z",
"requestId": "req_Ab12Cd34"
}
}
```
* `error.statusCode` is a **string**, not a number.
* `meta.requestId` is the value to quote to Tomorro support when reporting a failing call. Log it.
| Code | Meaning | Retry? |
| - | - | - |
| `401` | Missing or unknown `x-api-key`. | No |
| `403` | The API key's account cannot see this contract: use a superadmin account (1.1). | No |
| `404` | Unknown contract. | No |
| `409` | `signed-files`: no signed file on this contract (not signed yet). | No |
| `429` | Rate limit exceeded. | Yes, with backoff |
| `500` / timeout | Internal error. | Yes, with backoff |
### 3.5 - Edge cases
* **Duplicates**: name each file with the contract id and deduplicate webhook deliveries on `eventId`: a retried event or a re-run of Flow 0 overwrites instead of adding a copy.
* **Webhook disabled** after 10 failed deliveries: events fired meanwhile are not replayed. Re-enable it, then re-run Flow 0 to catch up (already saved contracts are skipped).
* **Contract deleted or canceled in Tomorro after signature**: the backup keeps its copy. Nothing is removed from the storage.
* **Amendment**: a new contract in Tomorro, so a new file with its own id.
* **Unsigned contracts** (draft, negotiating): out of scope, their document is not a final PDF yet.
* **Signature certificate**: not part of `signed-files`. Download it from the contract in Tomorro if your backup needs it.
# Full cycle example
Source: https://alpha.developer.tomorro.com/guides/connect-your-own-signature-tool/full-cycle-example
Connect your own signature tool: a working Yousign example with two Make scenarios
[Make](https://www.make.com) is a no-code automation platform. A **blueprint** is a Make scenario exported as JSON: import it (*Import blueprint*), connect your own accounts, and every module comes back configured.
The two blueprints below run every flow of this guide with Yousign: Tomorro → Yousign (Flows 1 and 3) and Yousign → Tomorro (Flows 2 and 4). Use them as a **head start**: import them if you run Make, read them as a reference implementation if you build on n8n, custom code or another signature tool.
> **[Get the 2 Make blueprints](https://drive.google.com/drive/folders/1YXxdcyQ7cBKlkSu5FGwl0nZx5Vk5-t0U)**
>
> The *3rd party* files: Tomorro → Third-Party (Flows 1 and 3) and Third-Party → Tomorro (Flows 2 and 4).
> **Notes**
>
> The two Make blueprints above are a working example, not a ready-made integration. They make choices you may want to change for your own setup:
>
> * **Yousign sandbox**: every Yousign call targets `api-sandbox.yousign.app`. Switch to `api.yousign.app` for production.
> * **Signature level**: `electronic_signature` (simple electronic signature), with `signature_authentication_mode: no_otp`, so signers get no SMS or email code.
> * **Language and time zone**: signers, emails and audit trail in English (`locale: en`), time zone `Europe/Paris`.
> * **Delivery**: `delivery_mode: email`, Yousign sends the invitations and reminders to the signatories.
> * **Signature zones**: only Tomorro fields of type `signature` are sent, `initials` are ignored. A signatory with no zone in Tomorro gets one at the bottom of the last page of the main document.
> * **Webhook secrets**: the Tomorro and Yousign webhook secrets are read from a Make data store.
## Yousign Full Demo
[Third-party signature demo: Yousign and Make](https://www.loom.com/embed/e511cf7fd89645a4ba3a2b03d8b877dd)
### Flow 1 - \[Tomorro → Yousign] Signature is requested
**1. Step 0 - Triggered by a user on Tomorro**
Triggered when a user clicks "Send for signature" on a Tomorro contract set to the "Other signature tool" option (after the approval workflow if there is one).
**2. Step 1 - Receive the signature.requested webhook**
*Make module 1*
Keep at least `data.contractId`, it is the key for every following call.
**3. Step 1.1 - Verify the Leeway signature**
*Make modules 2, 3*
The secret is read from the Make data store, key `tomorro_webhook_secret`. Inputs: the raw body, the request headers and the secret.
```javascript theme={null}
const crypto = require('crypto');
const { rawBody, headers, secret } = input;
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
const header = (headers || []).find((h) => ['leeway-signature', 'leeway_signature'].includes(String(h.name).toLowerCase()));
if (!header) return { valid: false, reason: 'missing Leeway-Signature header' };
const parts = Object.fromEntries(String(header.value).split(',').map((p) => p.trim().split('=')));
const { t, sha256 } = parts;
if (!t || !sha256) return { valid: false, reason: 'malformed Leeway-Signature header' };
if (Math.abs(Date.now() - Number(t)) > REPLAY_WINDOW_MS) return { valid: false, reason: 'timestamp outside the 5 minute window' };
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const matches = expected.length === sha256.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sha256));
if (!matches) return { valid: false, reason: 'signature mismatch' };
return { valid: true, event: JSON.parse(rawBody) };
```
**4. Step 1.2 - Route the event**
*Make module 4*
Continue only when `valid` is `true` and `test` is not `true`. Route on `eventType`: `signature.requested` → this flow, `signature.canceled` → Flow 3.
**5. Step 2 - Retrieve the files and signature information**
*Make module 5*
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}/signing-files
x-api-key:
```
**6. Step 3 - Retrieve more contract information (optional)**
*Make module 6*
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}
x-api-key:
```
**7. Step 4 - Work in Yousign**
**8. Step 4.1 - Build the Yousign signature request**
*Make module 7*
Inputs: `contractId`, `contractName` and `signatureNote` from the webhook, `firstSigningParty` from step 2. `external_id` = `contractId` is the link used by Flows 2, 3 and 4.
```javascript theme={null}
const { contractId, contractName, signatureNote, firstSigningParty } = input;
const body = {
name: String(contractName || 'Tomorro contract').slice(0, 128),
delivery_mode: 'email',
timezone: 'Europe/Paris',
ordered_signers: Boolean(firstSigningParty),
external_id: contractId,
audit_trail_locale: 'en',
};
if (signatureNote) body.email_custom_note = String(signatureNote).slice(0, 500);
return { body: JSON.stringify(body) };
```
**9. Step 4.2 - Create the Yousign signature request**
*Make module 8*
```http theme={null}
POST https://api-sandbox.yousign.app/v3/signature_requests
Authorization: Bearer
Content-Type: application/json
{
"name": "",
"delivery_mode": "email",
"timezone": "Europe/Paris",
"ordered_signers": true,
"external_id": "",
"audit_trail_locale": "en",
"email_custom_note": ""
}
```
**10. Step 4.3 - Download each file to sign**
*Make modules 9, 10*
Iterate over `data.files` from step 2, then download each `downloadUrl` (no auth header, valid 5 minutes).
```http theme={null}
GET
```
**11. Step 4.4 - Upload each document to Yousign**
*Make module 11*
```http theme={null}
POST https://api-sandbox.yousign.app/v3/signature_requests/{signatureRequestId}/documents
Authorization: Bearer
Content-Type: multipart/form-data
file = , filename
nature = signable_document
```
**12. Step 4.5 - Map each Tomorro file to its Yousign document**
*Make module 12*
Aggregate one entry per file, joined with `,`:
```json theme={null}
{"fileId":"","documentId":""}
```
**13. Step 4.6 - Build the Yousign signers**
*Make module 13*
Inputs: `data` from step 2 (`files`, `signatories`, `firstSigningParty`) and the mapping from step 4.5. Orders the signers, converts the Tomorro field positions into Yousign coordinates and cleans the names.
```javascript theme={null}
const { signingFiles, documents } = input;
const documentIdByFileId = Object.fromEntries(
JSON.parse(`[${documents}]`).map(({ fileId, documentId }) => [fileId, documentId]),
);
const files = signingFiles.files || [];
const signatories = signingFiles.signatories || [];
// Yousign signs in creation order: members ranked by order, guests on the side given by firstSigningParty
const members = signatories.filter((s) => s.type === 'member').sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const guests = signatories.filter((s) => s.type !== 'member');
const ordered = signingFiles.firstSigningParty === 'guest' ? [...guests, ...members] : [...members, ...guests];
// Yousign minimum signature field size
const WIDTH = 85;
const HEIGHT = 37;
const clamp = (value, max) => Math.max(0, Math.min(Math.round(value), Math.floor(max)));
const mainFile = files.find((f) => f.isMain) || files[0];
const signers = ordered.map((signatory, index) => {
const fields = files.flatMap((file) =>
(file.fields || [])
.filter((f) => f.signatoryId === signatory.id && f.type === 'signature' && documentIdByFileId[file.id])
.map((f) => {
const page = file.pages[f.pageIndex];
return {
document_id: documentIdByFileId[file.id],
type: 'signature',
page: f.pageIndex + 1,
x: clamp((f.positionX / 100) * page.width, page.width - WIDTH),
y: clamp((f.positionY / 100) * page.height, page.height - HEIGHT),
width: WIDTH,
height: HEIGHT,
};
}),
);
// No zone placed in Tomorro: Yousign needs one, so drop it at the bottom of the main document's last page
if (!fields.length && mainFile) {
const pageNumber = mainFile.pages.length;
const page = mainFile.pages[pageNumber - 1];
fields.push({
document_id: documentIdByFileId[mainFile.id],
type: 'signature',
page: pageNumber,
x: clamp(40 + (index % 4) * 130, page.width - WIDTH),
y: clamp(page.height - 80 - Math.floor(index / 4) * 60, page.height - HEIGHT),
width: WIDTH,
height: HEIGHT,
});
}
// name can be "" for a guest invited by email; Yousign rejects names with characters like . or _
const clean = (value) => value.replace(/[^\p{L}0-9`'() -]/gu, ' ').replace(/\s+/g, ' ').trim();
const fallback = clean(signatory.email.split('@')[0]) || 'Signataire';
const [firstName, ...lastNames] = (clean(signatory.name || '') || fallback).split(' ');
return {
email: signatory.email,
body: JSON.stringify({
info: {
first_name: firstName,
last_name: lastNames.join(' ') || firstName,
email: signatory.email,
locale: 'en',
},
signature_level: 'electronic_signature',
signature_authentication_mode: 'no_otp',
fields,
}),
};
});
return { signers };
```
**14. Step 4.7 - Add each signer to Yousign**
*Make modules 14, 15, 16*
Iterate over `signers` from step 4.6, one call per signer, in order:
```http theme={null}
POST https://api-sandbox.yousign.app/v3/signature_requests/{signatureRequestId}/signers
Authorization: Bearer
Content-Type: application/json
{
"info": { "first_name": "Jane", "last_name": "Doe", "email": "jane.doe@acme.com", "locale": "en" },
"signature_level": "electronic_signature",
"signature_authentication_mode": "no_otp",
"fields": [
{ "document_id": "", "type": "signature", "page": 1, "x": 241, "y": 227, "width": 85, "height": 37 }
]
}
```
**15. Step 4.8 - Activate the signature request**
*Make module 17*
Yousign sends the invitations to the signatories.
```http theme={null}
POST https://api-sandbox.yousign.app/v3/signature_requests/{signatureRequestId}/activate
Authorization: Bearer
Content-Type: application/json
{}
```
### Flow 2 - \[Yousign → Tomorro] Signature is completed
**1. Step 0 - Receive the Yousign webhook**
*Make module 1*
Subscribe this webhook in Yousign to `signature_request.done`, and to the events of Flow 4.
**2. Step 0.1 - Verify the Yousign signature**
*Make modules 2, 3*
The secret is read from the Make data store, key `yousign_webhook_secret`. Inputs: the raw body, the request headers and the secret.
```javascript theme={null}
const crypto = require('crypto');
const { rawBody, headers, secret } = input;
const header = (headers || []).find((h) => String(h.name).toLowerCase() === 'x-yousign-signature-256');
if (!header) return { valid: false, reason: 'missing X-Yousign-Signature-256 header' };
const received = String(header.value).trim();
const expected = `sha256=${crypto.createHmac('sha256', secret).update(rawBody).digest('hex')}`;
const matches = expected.length === received.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!matches) return { valid: false, reason: 'signature mismatch' };
return { valid: true, event: JSON.parse(rawBody) };
```
**3. Step 0.2 - Route the event**
*Make module 4*
Continue only when `valid` is `true`. This flow takes `event_name` = `signature_request.done` with a `data.signature_request.external_id` (the Tomorro `contractId`). The other events go to Flow 4.
**4. Step 0.3 - Download each signed document from Yousign**
*Make modules 5, 6*
Iterate over `data.signature_request.documents`, keep `nature` = `signable_document`:
```http theme={null}
GET https://api-sandbox.yousign.app/v3/signature_requests/{signatureRequestId}/documents/{documentId}/download
Authorization: Bearer
```
**5. Step 1 - Get an upload URL, one per PDF**
*Make module 7*
```http theme={null}
POST https://api.tomorro.com/v2/files/upload-url
x-api-key:
Content-Type: application/json
{ "contentType": "application/pdf" }
```
**6. Step 2 - Upload the PDF to data.url**
*Make module 8*
No auth header, raw bytes of the PDF downloaded in step 0.3.
```http theme={null}
PUT
Content-Type: application/pdf
```
**7. Step 3 - Push the contract to Tomorro**
**8. Step 3.1 - Collect the uploaded files**
*Make module 9*
Aggregate one row per PDF, joined with a new line:
```text theme={null}
|
```
**9. Step 3.2 - Build the signed-version body**
*Make module 10*
Inputs: the rows from step 3.1, `data.signature_request.completed_at` and `event_time` from the Yousign webhook.
```javascript theme={null}
const { uploads, completedAt, eventTime } = input;
const files = uploads.split('\n').filter(Boolean).map((row) => {
const [key, ...rest] = row.split('|');
const name = rest.join('|').trim() || 'Signed document.pdf';
return { key, name: /\.pdf$/i.test(name) ? name : `${name}.pdf`, contentType: 'application/pdf' };
});
// event_time is a unix timestamp; Tomorro refuses a signatureDate in the future
const toDate = (value) => new Date(/^\d+$/.test(String(value)) ? Number(value) * 1000 : value);
const signedAt = toDate(completedAt || eventTime);
const signatureDate = new Date(Math.min(signedAt.getTime(), Date.now())).toISOString();
return { body: JSON.stringify({ files, signatureDate }) };
```
**10. Step 3.3 - Push the signed version**
*Make module 11*
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{external_id}/signed-version
x-api-key:
Content-Type: application/json
{
"files": [
{ "key": "", "name": "NDA - Acme.pdf", "contentType": "application/pdf" }
],
"signatureDate": "2026-09-07T10:15:00.000Z"
}
```
**11. Step 3.4 - Accept 409 ContractAlreadySigned**
*Make module 12*
Inputs: status code and body of step 3.3 (the module runs with *Evaluate all states as errors* off).
```javascript theme={null}
const { statusCode, body } = input;
const status = Number(statusCode);
if (status >= 200 && status < 300) return { outcome: 'signed' };
const parsed = typeof body === 'string' ? (() => { try { return JSON.parse(body); } catch { return {}; } })() : body || {};
// A previous delivery of the same Yousign webhook already pushed the signed version
if (status === 409 && parsed.error?.errorId === 'ContractAlreadySigned') return { outcome: 'alreadySigned' };
throw new Error(`signed-version failed with ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`);
```
### Flow 3 - \[Tomorro → Yousign] Signature canceled in Tomorro
**1. Step 0 - A user cancels the signature on Tomorro**
**2. Step 1 - Receive the signature.canceled webhook**
*Make modules 1, 2, 3, 4*
Same webhook, same verification and same router as Flow 1 step 1. This route takes `eventType` = `signature.canceled`.
**3. Step 2 - Cancel in Yousign**
**4. Step 2.1 - Find the Yousign signature request**
*Make module 18*
Look it up by `external_id` = `contractId`, among the requests still running:
```http theme={null}
GET https://api-sandbox.yousign.app/v3/signature_requests?external_id[eq]={contractId}&status[in]=ongoing,approval
Authorization: Bearer
```
**5. Step 2.2 - Build the Yousign cancel body**
*Make module 19*
Input: `data.cancelReason` from the webhook.
```javascript theme={null}
const { cancelReason } = input;
const body = { reason: 'contractualization_aborted' };
if (cancelReason) body.custom_note = String(cancelReason).slice(0, 500);
return { body: JSON.stringify(body) };
```
**6. Step 2.3 - Cancel each matching request**
*Make modules 20, 21*
Iterate over `data` from step 2.1:
```http theme={null}
POST https://api-sandbox.yousign.app/v3/signature_requests/{signatureRequestId}/cancel
Authorization: Bearer
Content-Type: application/json
{ "reason": "contractualization_aborted", "custom_note": "" }
```
Yousign then fires `signature_request.canceled`, which reaches Flow 4: its `cancel-signatures` call answers `400`, accepted as expected.
### Flow 4 - \[Yousign → Tomorro] Signature canceled in Yousign
**1. Step 0 - Receive the Yousign webhook**
*Make modules 1, 2, 3, 4*
Same webhook, same verification and same router as Flow 2 step 0. This route takes `event_name` in `signature_request.declined`, `.rejected`, `.expired`, `.canceled`, `.deleted`, `.permanently_deleted`, with a `data.signature_request.external_id`.
**2. Step 1 - Cancel the signature in Tomorro**
**3. Step 1.1 - Build the cancel-signatures body**
*Make module 13*
Input: `event_name` from the Yousign webhook.
```javascript theme={null}
const { eventName } = input;
const reasons = {
'signature_request.declined': 'Refused by a signatory in Yousign',
'signature_request.rejected': 'Rejected by an approver in Yousign',
'signature_request.expired': 'Signature request expired in Yousign',
'signature_request.canceled': 'Signature request canceled in Yousign',
'signature_request.deleted': 'Signature request deleted in Yousign',
'signature_request.permanently_deleted': 'Signature request deleted in Yousign',
};
return { body: JSON.stringify({ cancelReason: reasons[eventName] || 'Signature stopped in Yousign' }) };
```
**4. Step 1.2 - Cancel the signature**
*Make module 14*
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{external_id}/cancel-signatures
x-api-key:
Content-Type: application/json
{ "cancelReason": "Refused by a signatory in Yousign" }
```
**5. Step 1.3 - Accept 400**
*Make module 15*
Inputs: status code and body of step 1.2. A `400` means the contract is no longer in `signing` (echo of Flow 3, or already signed).
```javascript theme={null}
const { statusCode, body } = input;
const status = Number(statusCode);
if (status >= 200 && status < 300) return { outcome: 'canceled' };
// Contract no longer in signing: already canceled in Tomorro (echo of Flow 3, or a retry that went through) or signed
if (status === 400) return { outcome: 'notInSigning' };
throw new Error(`cancel-signatures failed with ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`);
```
**6. Step 2 - Ignore the echo webhook**
Tomorro fires `signature.canceled` back, which reaches Flow 3. Step 2.1 there only searches the `ongoing` and `approval` requests, finds none, and the flow stops.
# Non-Technical scoping
Source: https://alpha.developer.tomorro.com/guides/connect-your-own-signature-tool/non-technical-scoping
Connect your own signature tool: what the integration covers, in five flows
**Scope:** this guide only covers the exchanges between **Tomorro and the integration** (the middleware built by the customer). What the integration does with the third-party signature tool (Yousign, Universign, Adobe Sign, LuxTrust, Oodrive...) is out of scope and entirely on the customer's side.
**TL;DR**
Five flows, all between Tomorro and your middleware:
* **Flow 1: Signature is requested.** A user sends for signature in Tomorro. Tomorro fires `signature.requested`, you fetch the document, fields and signatories, then run the signature in your own tool. **Tomorro sends nothing to the signatories at any point**: your tool owns the whole signer experience, so no double notification.
* **Flow 2: Signature is completed.** Once everyone has signed in your tool, you push the signed PDF back. Tomorro moves the contract to `signed` and runs its post-signature automations.
***
* **Flow 3: Signature canceled in Tomorro.** A user cancels from Tomorro, Tomorro fires `signature.canceled`, you kill the envelope on your side. Nothing to call back.
* **Flow 4: Signature canceled in your tool.** Refused, expired or canceled there: you call `cancel-signatures` and the contract goes back to `draft` or `negotiating`.
***
* **Flow 5: Attachments (optional).** Push the signature certificate, or any supporting document produced by your tool (PDF, Word, Excel...), onto the contract, one file per call.
# Technical recipes
Source: https://alpha.developer.tomorro.com/guides/connect-your-own-signature-tool/technical-recipes
Connect your own signature tool: prerequisites, the five flows and the API reference for your IT team
```mermaid theme={null}
sequenceDiagram
autonumber
actor U as Tomorro user
participant T as Tomorro
participant S3 as Tomorro S3
pre-signed URLs
participant I as Integration
customer middleware
participant P as Third-party tool
OUT OF SCOPE
Note over U,P: Prerequisites: "automation" superadmin account (access to every contract), API key generated and webhook configured
rect rgb(232, 244, 253)
Note over U,P: Flow 1: signature requested (Tomorro -> Third-party)
U->>T: Clicks Send for signature (provider = 'third-party')
Note right of T: Fired after the approval workflow if there is one.
Tomorro sends nothing to the signatories.
T->>I: POST webhook signature.requested
header Leeway-Signature, timeout 3 s
I-->>T: 200 OK
I->>T: GET /v2/contracts/{id}/signing-files
T-->>I: files (downloadUrl) + fields + signatories + firstSigningParty
loop Each file (main document, then annexes)
I->>S3: GET downloadUrl (valid 5 min)
S3-->>I: PDF binary
end
opt Extra contract data
I->>T: GET /v2/contracts/{id}
T-->>I: contract type, smart fields, dates...
end
I->>P: create the envelope, collect the signatures
P-->>I: signed PDF
end
rect rgb(232, 244, 253)
Note over U,P: Flow 2: Signature is complete (Third-party -> Tomorro)
loop Each signed PDF (main document first, then annexes)
I->>T: POST /v2/files/upload-url
T-->>I: url + key
I->>S3: PUT signed PDF binary (direct S3, no Tomorro auth header)
S3-->>I: 200 OK
end
I->>T: POST /v2/contracts/{id}/signed-version
files[] (every key, once) + signatureDate
T-->>I: 204 No Content
Note left of T: Contract moves to signed, post-signature automations run
(notifications, contract.signed webhook, syncs).
U->>T: Consults the contract (signed status + signed PDF)
end
rect rgb(243, 229, 245)
Note over U,P: Flow 3: Signature canceled in Tomorro
U->>T: Cancel the signature process
Note right of T: Contract is already back to 'draft' or 'negotiating'.
T->>I: POST webhook signature.canceled (with cancelReason)
I-->>T: 200 OK
I->>P: Kill the envelope
Note left of I: No call back to Tomorro (cancel-signatures would answer 400). The flow ends here.
end
rect rgb(255, 235, 238)
Note over U,P: Flow 4: Signature canceled in the third-party tool
P-->>I: Refused / expired / canceled
I->>T: POST /v2/contracts/{id}/cancel-signatures
cancelReason (optional)
Note left of T: Contract goes back to 'draft' or 'negotiating', participants notified
T-->>I: 204 No Content
T->>I: POST webhook signature.canceled (echo of this cancel)
I-->>T: 200 OK
Note left of I: Envelope already dead on your side: acknowledge and ignore.
end
rect rgb(245, 229, 164)
Note over U,P: Flow 5 (optional): Attachments (e.g. certificate), any status, never changes it
loop Each attachment (one file per call)
I->>T: POST /v2/files/upload-url (contentType)
T-->>I: url + key
I->>S3: PUT file binary (direct S3, no Tomorro auth header)
S3-->>I: 200 OK
I->>T: POST /v2/contracts/{id}/attachments
key + name + contentType + isSharedWithGuest
T-->>I: 204 No Content
end
end
```
## 1. Prerequisites and configuration
**1. 1.1 - Create Tomorro superadmin account**
An existing admin creates a dedicated **superadmin** account from **Settings > Members** (e.g. `automation@customer.com`), not a personal account.
* The API key is bound to a **member**, not to the organization. Every write performed through the API is attributed to that member and inherits its permissions.
* Consequence:
* the contract activity log will show this account as the author of the signed-file import and of the cancellations. A named account such as "Signature automation" keeps the timeline readable.
* a **superadmin** has access to **every contract** of the workspace: the integration can query any of them (`GET`, `POST`, `PATCH`) without the account being added as a participant. With any other role, the account only sees the contracts it takes part in, and the others are rejected with a `403`. See 3.4.
* Recommended: no personal mailbox behind it, and exclude it from Tomorro notification settings.
**2. 1.2 - API key**
Generate the API key from that superadmin account. It does not expire. It is sent on every REST call in the `x-api-key` header: [Step 1: Get your API key](/quickstart)
**3. 1.3 - Webhook setup in the admin console**
[https://app.tomorro.com/settings/integrations?integration=othersignaturetool](https://app.tomorro.com/settings/integrations?integration=othersignaturetool)
Admin only, and **one endpoint per workspace**: the console edits a single configuration, there is no list and no "Add". Every contract of the organization sent through the third-party option goes to that one URL.
* **Integration name (mandatory)**: the label displayed in the Tomorro UI in place of "Tomorro" or "Docusign" (e.g. `Yousign`, `Universign`...).
* **URL (mandatory)**: the integration endpoint that will receive the events. HTTPS, publicly reachable.
* **Secret**: generated at creation, one single secret signs both events. Revealed on demand in the console. Editing the integration name or the URL does **not** rotate it, so an integration already deployed against it keeps verifying.
* **Events**: `signature.requested` and `signature.canceled` are both subscribed automatically. They are shown for information and cannot be turned off separately. The integration needs both, since a signature that starts must also be able to be canceled.
* **Test buttons**: one per event. Each sends a synthetic payload to the URL to validate reachability and signature verification before going live. See 3.2.4. **Your endpoint must recognize these and ignore them**.
## 2. Flows
### Flow 1 - \[Tomorro → Third-Party] Signature is requested
**1. Step 0 - Triggered by a user on Tomorro**
Triggered when a user clicks "Send for signature" on a Tomorro contract set to the "Other signature tool" option (after the approval workflow if there is one).


**2. Step 1 - Receive the signature.requested webhook**
Keep at least `data.contractId`, it is the key for every following call.
Projected payload:
```json theme={null}
{
"eventId": "9aa01b69-...",
"webhookId": "eeaa7b6d-...",
"createdAt": "2026-09-04T14:32:10.000Z",
"eventType": "signature.requested",
"data": {
"organizationId": "...",
"contractId": "...",
"signatureId": "...",
"contractName": "NDA - Acme",
"counterparty": { "id": "...", "name": "Acme" },
"signatories": [
{ "id": "...", "name": "Jane Doe", "email": "jane.doe@acme.com", "order": 0, "type": "member", "title": "CEO" },
{ "id": "...", "name": "John Doe", "email": "john.doe@external.com", "order": null, "type": "guest", "title": null }
],
"contractMembers": [ { "id": "...", "email": "..." } ],
"signatureNote": "...",
"cancelReason": "..." // only for signature.canceled
}
}
```
> **Notes**
>
> * `signatureNote`: the message typed by the sender, to reuse as the envelope message.
> * `contractMembers`: the Tomorro members of the contract, for instance to copy them on the envelope if your tool supports it.
> * `signatureId`: the Tomorro signature this event is about. A cancel then re-send on the same contract opens a new one, and the `signature.canceled` of a ceremony carries the same `signatureId` as its `signature.requested`, so you can tie them together beyond `contractId`.
**3. Step 2 - Retrieve the files and signature information**
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}/signing-files
x-api-key:
```
Response:
```json theme={null}
{
"data": {
"contractId": "0fb3e924-...",
"firstSigningParty": "member",
"files": [
{
"id": "1ad5b1e9-...",
"isMain": true,
"order": 1,
"filename": "NDA - Acme.pdf",
"contentType": "application/pdf",
"downloadUrl": "https://s3...",
"expiresAt": "2026-09-07T10:15:00.000Z",
"pages": [{ "width": 595.303937007874, "height": 841.889763779528 }],
"fields": [
{
"id": "b9eaf0d0-...",
"signatoryId": "0e8f0597-...",
"type": "signature",
"pageIndex": 0,
"positionX": 40.5,
"positionY": 26.9598
},
{
"id": "16f6ac2a-...",
"signatoryId": "0e8f0597-...",
"type": "initials",
"pageIndex": 0,
"positionX": 69.2466,
"positionY": 87.8171
}
]
}
],
"signatories": [
{
"id": "0e8f0597-...",
"name": "Alex Smith",
"email": "alex.smith@acme.com",
"type": "member",
"title": null,
"order": 0
},
{
"id": "c94d5d06-...",
"name": "Jane Doe",
"email": "jane.doe@acme.com",
"type": "member",
"title": "CEO",
"order": 1
},
{
"id": "2f768c84-...",
"name": "",
"email": "john.doe@external.com",
"type": "guest",
"title": null,
"order": null
}
]
}
}
```
> **Notes**
>
> * `downloadUrl` is a pre-signed URL valid **5 minutes**, see `expiresAt`. Download the file straight away and do not store the URL. Re-call the endpoint to get a fresh one.
> * `isMain` distinguishes the main document from its annexes, `files[].order` gives the order of the bundle.
> * **Signing order**: `firstSigningParty` says which side signs first (`null` when the signature is not ordered). Only internal signatories (`member`) are ranked among themselves, with `signatories[].order`. Guests are never ranked: their `order` is always `null`. In the example above: Alex, then Jane, then the guest. See 3.3.
> * `fields` gives the position of the signature zones placed in Tomorro. Use them, or let the third-party tool place its own. Coordinates are explained in 3.3.
> * Returns `409` when there is no signature in progress on the contract: never sent for signature, canceled, or already signed. In the last case the executed PDFs are on `GET /v2/contracts/{id}/signed-files`.
>
> *View enum details in 3.3*
**4. Step 3 - Retrieve more contract information (optional)**
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}
```
Only needed if the third-party tool requires data that is not in the `signature.requested` payload or in `signing-files` (contract type, smart fields, dates, counterparty details).
**5. Step 4 - Work in the third-party tool**
Out of scope. Create the envelope, send it, wait for the signature.
While the signature is running in the third-party tool, **Tomorro sends nothing to the signatories**: no request email, no reminder, no magic link. The contract simply sits in `Signing`.
**Keep the link to the contract.** Store `contractId` on the envelope (external id, metadata or custom field, depending on your tool), or keep your own `contractId` ↔ envelope table. Flows 2 and 4 need it to know which contract to call back, and Flow 3 needs it to find the envelope to kill.
### Flow 2 - \[Third-Party → Tomorro] Signature is completed
**1. Step 0 - Trigger on the third-party tool**
Out of scope.
> [!NOTE]
>
> Steps 1 to 3 push the signed PDFs back to Tomorro. Each PDF (main document and each annex) goes through steps 1 and 2. Step 3 then runs **once**, with every file.
**2. Step 1 - Get an upload URL, one per PDF**
```http theme={null}
POST https://api.tomorro.com/v2/files/upload-url
x-api-key:
Content-Type: application/json
{ "contentType": "application/pdf" }
```
Response `201`:
```json theme={null}
{
"data": {
"url": "https://s3.eu-west-3.amazonaws.com/...",
"key": "signedFiles/392ef54d-.../files/59fb54e9-..."
}
}
```
> **Notes**
>
> * Read the values under `data`: `data.url` and `data.key`.
> * `data.url` is valid **5 minutes**. Do step 2 straight away.
> * Keep `data.key` for step 3. Once the file is uploaded, the key stays usable until a successful step 3 consumes it.
**3. Step 2 - Upload the PDF to data.url**
This call goes to the storage URL, **not** to the Tomorro API.
```http theme={null}
PUT
Content-Type: application/pdf
```
> **Notes**
>
> * **The body is the raw file bytes**: not `multipart/form-data`, not base64, not JSON.
> * n8n: HTTP Request node, body content type **n8n Binary File**.
> * Make: HTTP module, send the file data as the raw request body.
> * Code: `curl -X PUT --data-binary @signed.pdf -H "Content-Type: application/pdf" ""`
> * **No `x-api-key` and no `Authorization` header.** The URL carries its own signature. An extra auth header gets the upload refused.
> * `Content-Type` must be `application/pdf`, the same value as in step 1.
> * **Send the real PDF.** Storage accepts any bytes here, but Tomorro reads the uploaded file in step 3 and refuses anything that is not a PDF (`400 SignedFileNotPdf`), whatever `Content-Type` was declared.
> * Use `data.url` exactly as returned, query string included. Do not decode or re-encode it.
> * Expected response: `200`, empty body.
**4. Step 3 - Push the contract to Tomorro**
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{contractId}/signed-version
x-api-key:
Content-Type: application/json
{
"files": [
{
"key": "signedFiles/392ef54d-.../files/59fb54e9-...",
"name": "NDA - Acme (signed).pdf",
"contentType": "application/pdf"
},
{
"key": "signedFiles/392ef54d-.../files/ba093c1c-...",
"name": "Annex 1 (signed).pdf",
"contentType": "application/pdf"
}
],
"signatureDate": "2026-09-07T10:15:00.000Z"
}
```
Response: `204 No Content`, **empty body**. Do not try to parse JSON on success.
> **Notes**
>
> | Field | Rule |
> | - | - |
> | `files` | 1 to 20 PDFs, **main document first**, then its annexes. The order is kept. |
> | `files[].key` | `data.key` from step 1, once step 2 has succeeded. |
> | `files[].name` | File name shown in Tomorro. Must end with `.pdf`. |
> | `files[].contentType` | Always `application/pdf`. Any other type is refused. |
> | `signatureDate` | When the **last signatory signed** in your tool, not when you make the call. ISO 8601 with a time zone: `2026-09-07T10:15:00.000Z` or `2026-09-07T12:15:00+02:00`. Cannot be in the future. |
>
> The body is strict: any other field is refused with a `400`. That includes `contractId`, which already goes in the URL.
>
> What Tomorro does on receipt:
>
> * attaches the PDFs as the contract's signed files, and marks the document that was sent for signature as signed
> * marks every signatory as signed on `signatureDate`
> * moves the contract to `signed` and stamps its signature date. If the contract type sets start date = signature date, the start date takes the same value
> * runs the usual post-signature automations: notification to the contract members, `contract.signed` webhook, syncs…
>
> **Errors on step 3**
>
> | Code | `errorId` | Meaning | What to do |
> | - | - | - | - |
> | `400` | none, see `details` | Invalid body: not a PDF, name without `.pdf`, `signatureDate` missing, invalid or in the future, 0 or more than 20 files, unknown field. | Fix the payload. Do not retry as is. |
> | `400` | `FileIncorrectFileKey` | A `key` is unknown, belongs to another workspace, was already consumed, or no file was uploaded under it (step 2 skipped or sent to another URL). | Redo steps 1 and 2 for that file, then step 3. |
> | `400` | `SignedFileNotPdf` | A file uploaded in step 2 is not a real PDF, even though it was declared as `application/pdf` (e.g. a Word document). | Upload the actual signed PDF (steps 1 and 2), then step 3. |
> | `409` | `ContractAlreadySigned` | The contract is already signed, typically by your own previous call. | Treat as success. |
> | `409` | `ContractCanceled` | The contract was canceled in Tomorro. | Stop, there is nothing to sign. |
> | `409` | `SignatureStartedInAnotherProvider` | A signature is in progress on the contract with Tomorro or DocuSign, not with your tool. | Stop. A Tomorro user must cancel that signature first. |
>
> On a `409`, `error.message` only says `Conflict`: **branch on `error.errorId`**.
>
> A refused call (`400` or `409`) changes nothing on the contract, and the keys stay usable.
>
> **Retries**
>
> If step 3 times out or answers `5xx`, you cannot tell whether it went through. Call `GET /v2/contracts/{contractId}`:
>
> * `data.status` is `signed`: done.
> * otherwise: send step 3 again with the same body. The keys are still valid, since only a `204` consumes them.
>
> A `409 ContractAlreadySigned` on a retry means the first call worked.
>
> **Also usable outside this flow**
>
> The same endpoint accepts a contract in `draft` or `negotiating` that was never sent for signature, for instance to import a contract signed elsewhere. The first PDF then becomes a new version of the document, marked signed. Everything else is identical.
### Flow 3 - \[Tomorro → Third-Party] Signature canceled in Tomorro
**1. Step 0 - A user cancels the signature on Tomorro**

**2. Step 1 - Receive the signature.canceled webhook**
Same envelope and same verification as 3.2.1 and 3.2.2, with `eventType` = `signature.canceled`.
`data` carries one field the `signature.requested` payload does not: `cancelReason`, the reason typed by whoever canceled in Tomorro. It is a string, or `null` when no reason was given. Carry it into the cancellation on your side rather than voiding the envelope with no explanation.
```json theme={null}
{
"eventId": "757b6308-...",
"webhookId": "eeaa7b6d-...",
"createdAt": "2026-09-21T07:38:11.027Z",
"eventType": "signature.canceled",
"data": {
"organizationId": "...",
"contractId": "...",
"signatureId": "...",
"contractName": "NDA - Acme",
"counterparty": { "id": "...", "name": "Acme" },
"signatories": [ "... same shape as signature.requested ..." ],
"contractMembers": [ "... same shape as signature.requested ..." ],
"signatureNote": null,
"cancelReason": "Wrong counterparty entity"
}
}
```
**3. Step 2 - Cancel in the third-party tool**
Out of scope. Kill the envelope on the third-party side so the signatories stop receiving its reminders.
***No call back to Tomorro. The flow ends at step 2.***
*The signature is already canceled on the Tomorro side, the contract is already back to `draft` or `negotiating`. Do **not** call `cancel-signatures` here. It would be rejected with a `400`, since the contract is no longer in `signing`.*
**Expect the echo from your tool.** Killing the envelope usually makes your tool fire its own "canceled" event, which starts your Flow 4. There, `cancel-signatures` answers `400` because the contract is no longer in `signing`: this is expected, treat it as success. To skip the extra call, ignore that event in Flow 4 when the cancel came from Tomorro.
### Flow 4 - \[Third-Party → Tomorro] Signature canceled in the third-party tool
**1. Step 0 - Trigger on the third-party tool**
The signature will not complete: refused, canceled or expired in the third-party tool.
Out of scope.
**2. Step 1 - Cancel the signature in Tomorro**
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{contractId}/cancel-signatures
x-api-key:
{ "cancelReason": "Refused by the signatory in Yousign" }
```
`cancelReason` is optional.
> **Notes**
>
> Tomorro runs the same flow as the cancel button in the app: the contract leaves `Signing`, goes back to `draft` or `negotiating`, and the contract participants are notified.
>
> Response: `204 No Content`, **empty body**. Rejected with a `400` if the contract is not in `signing` (never sent for signature, already signed or canceled).
>
> **Retry**: if the call times out or answers `5xx`, send it again. A `400` on that retry means the first call went through: treat it as success.
**3. Step 2 - Ignore the echo webhook**
This cancellation also fires `signature.canceled` to your endpoint, exactly like a cancel made in Tomorro (Flow 3). The envelope is already dead on your side: verify the signature, answer 2xx, and do nothing else.
### Flow 5 - \[Third-Party → Tomorro] Add attachments (optional)
Adds a file to the contract's attachments, next to its signed document. Typically the **signature certificate** when your tool does not put it in the signed bundle, or any supporting document it produces (audit trail, evidence file...).
* **When**: usually right after Flow 2 has answered `204`. The endpoint works on a contract in any status and **never changes the status**.
* **One file per call.** Unlike `signed-version`, which takes every PDF in one call, each attachment runs through steps 1, 2 and 3 on its own.
* **Not only PDFs**: PDF, Word, Excel and more. See *Accepted file types* in 3.3.
> [!NOTE]
>
> Steps 1 and 2 are the same upload as in Flow 2, for any accepted file type. Each attachment goes through steps 1 to 3 on its own. The **same `contentType`** is sent in all three steps, and the file `name` must end with the **matching extension** (`.pdf` for `application/pdf`, `.xlsx` for Excel...).
**1. Step 1 - Get an upload URL, one per file**
```http theme={null}
POST https://api.tomorro.com/v2/files/upload-url
x-api-key:
Content-Type: application/json
{ "contentType": "application/pdf" }
```
Replace `application/pdf` with the content type of your file (see *Accepted file types*).
Response `201`:
```json theme={null}
{
"data": {
"url": "https://s3.eu-west-3.amazonaws.com/...",
"key": "signedFiles/392ef54d-.../files/ba093c1c-..."
}
}
```
> **Notes**
>
> * Read the values under `data`: `data.url` and `data.key`.
> * n8n: in the following nodes, reference them by the node name, e.g. `{{ $('Get upload URL').item.json.data.key }}`.
> * Make: enable **Parse response** on the HTTP module so `data.url` and `data.key` can be mapped in the next modules.
> * `data.url` is valid **5 minutes**. Do step 2 straight away.
> * Keep `data.key` for step 3. Once the file is uploaded, the key stays usable until a successful step 3 consumes it. Pass it **unchanged** (no trimming, no leading `/`, not the URL): one key = one file = one attachment.
> * A content type that is not accepted is already refused here with `400 FileContentTypeNotAllowed`.
**2. Step 2 - Upload the file to data.url**
This call goes to the storage URL, **not** to the Tomorro API.
```http theme={null}
PUT
Content-Type: application/pdf
```
> **Notes**
>
> * **The body is the raw file bytes**: not `multipart/form-data`, not base64, not JSON.
> * n8n: HTTP Request node, method `PUT`, URL `data.url`, body content type **n8n Binary File**, pointing at the binary property that holds the file.
> * Make: HTTP module, method `PUT`, send the file data as the raw request body.
> * Code: `curl -X PUT --data-binary @certificate.pdf -H "Content-Type: application/pdf" ""`, or in Node.js `await fetch(url, { method: 'PUT', headers: { 'Content-Type': contentType }, body: fileBuffer })`.
> * **No `x-api-key` and no `Authorization` header.** The URL carries its own signature. An extra auth header gets the upload refused. In n8n and Make, make sure this node does not reuse the Tomorro credential (authentication: none).
> * `Content-Type` must be the same value as in step 1.
> * Use `data.url` exactly as returned, query string included. Do not decode or re-encode it.
> * Expected response: `200`, empty body.
**3. Step 3 - Attach the file to the contract**
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{contractId}/attachments
x-api-key:
Content-Type: application/json
{
"key": "signedFiles/392ef54d-.../files/ba093c1c-...",
"name": "Signature certificate.pdf",
"contentType": "application/pdf",
"isSharedWithGuest": true
}
```
Response: `204 No Content`, **empty body**. Do not try to parse JSON on success.
> **Notes**
>
> | Field | Rule |
> | - | - |
> | `key` | **Required.** `data.key` from step 1, once step 2 has succeeded. |
> | `name` | **Required.** File name shown in Tomorro. Must end with the extension matching `contentType`. |
> | `contentType` | **Required.** The same value as in steps 1 and 2. See *Accepted file types*. |
> | `isSharedWithGuest` | Optional boolean, **default `false`**. `true` = **external**: the file is also shared with the contract guests, so the counterparty sees it. `false` = **internal**: only the members of your organization see it. |
>
> The body is strict and **flat**: the four fields sit at the root, with no `file` wrapper. Any other field is refused with a `400`, including `contractId`, which already goes in the URL.
>
> See *Accepted file types* below in 3.3.
>
> **Errors on step 3**
>
> | Code | `errorId` | Meaning | What to do |
> | - | - | - | - |
> | `400` | none, see `details` | Invalid body: a required field is missing or empty, an unknown field is sent (`file`, `contractId`...), `isSharedWithGuest` is not a boolean. | Fix the payload. Do not retry as is. |
> | `400` | `FileContentTypeNotAllowed` | `contentType` is not in the accepted list. | Use a supported format (3.3). |
> | `400` | `FileExtensionMismatchContentType` | The extension of `name` does not match `contentType`, e.g. `report.xlsx` sent as `application/pdf`. | Fix `name` or `contentType`. |
> | `400` | `FileIncorrectFileKey` | `key` is not the value returned by step 1: altered, empty, or obtained with an API key of another workspace. | Pass `data.key` exactly as returned. If in doubt, redo steps 1 and 2. |
> | `403` | none | The contract does not exist or is not visible to the automation account, the account is not allowed to add files, or attachments are not included in your plan. Indistinguishable, on purpose. | Do not retry. Check 1.1. |
>
> A refused call changes nothing on the contract.
>
> **Retries**
>
> A key is consumed by a successful step 3. If step 3 times out or answers `5xx`, retry **once** with the same body: if the first call actually went through, the retry is refused and the file is already on the contract. Do not start over from step 1 in that case, or the file ends up attached twice.
>
> **Also usable outside this flow**
>
> The endpoint is not tied to the signature: any integration can use it to add a supporting document to any contract, in any status.
## 3. Technical references
Reference material: conventions, webhook mechanics, enums and errors. Read it once, then come back to it while implementing the flows above.
### 3.1 - API conventions
* **REST only.** The whole integration runs on the public REST API.
* **Base URL**: `https://api.tomorro.com/v2`
* **Authentication**: `x-api-key: ` on every call. A missing or unknown key returns 401.
* **Versioning**: optional `tomorro-version` header, defaults to the latest version.
* **Rate limit**: 40 calls per minute, well above what this integration needs.
* **Sandbox**: available on request. It is an empty sandbox, not a copy of your production workspace.
* **Content type**: `application/json`
* **Response envelope**: every **successful** JSON response is wrapped in a `data` object (errors come as `{ error, meta }`, see 3.4). Read `data.url`, `data.files` and so on. A `204` response has no body at all.
### 3.2 - Receiving webhooks
Full documentation: [Technical documentation - Tomorro API](/webhooks/technical-documentation)
#### 3.2.1 - Payload
Tomorro sends a `POST` to the configured URL. Both events use the same envelope and the same `data` shape, the business payload sits under `data`.
These webhooks are organization-based, meaning every contract sent for signature through the third-party option will fire.
A delivery triggered by the **test buttons** in the admin console carries one extra top-level field, `"test": true`, absent from every genuine event. See 3.2.4.
```json theme={null}
{
"eventId": "9aa01b69-...",
"webhookId": "eeaa7b6d-...",
"createdAt": "2026-09-04T14:32:10.000Z",
"eventType": "signature.requested",
"data": {
"organizationId": "...",
"contractId": "...",
"signatureId": "...",
"contractName": "NDA - Acme",
"counterparty": { "id": "...", "name": "Acme" },
"signatories": [
{ "id": "...", "name": "Jane Doe", "email": "jane.doe@acme.com", "order": 0, "type": "member", "title": "CEO" },
{ "id": "...", "name": "John Doe", "email": "john.doe@external.com", "order": null, "type": "guest", "title": null }
],
"contractMembers": [ { "id": "...", "email": "..." } ],
"signatureNote": "...",
"cancelReason": "..." // only for signature.canceled
}
}
```
#### 3.2.2 - Signature verification
Header `Leeway-Signature` (also sent as `Leeway_Signature`):
```text theme={null}
Leeway-Signature: t=1788860270398,sha256=3f1c9d02a7...
```
Verification steps:
1. Split the header on `,` to get `t` (timestamp in milliseconds) and `sha256`.
2. Recompute `HMAC-SHA256(secret, ".")` in hexadecimal. **Use the raw body**, not a re-serialized JSON object, otherwise the hash will not match.
3. Compare in constant time with the `sha256` value.
4. Reject the call if the signature does not match, and reject a `t` that is too old to protect against replay (5 minutes is a reasonable window).
#### 3.2.3 - Acknowledgement and retries
* **Timeout 3000 ms.** The endpoint must answer 2xx in under 3 seconds. Acknowledge first, do the work asynchronously.
* Any non-2xx answer or timeout triggers a retry: **10 attempts in total** (first delivery + 9 retries), 5 minutes apart, about 45 minutes. The count is per event, but reaching it disables the whole webhook.
* **After the 10th failure the webhook is automatically disabled** (`enabled = false`). It has to be re-enabled in the admin console, and the "Other signature tool" option disappears from the UI while it is disabled. A contract that was already switched to the provider keeps it listed, but cannot be sent for signature until the endpoint is active again.
* **A `signature.requested` that fails all 10 attempts is rolled back in Tomorro**: the signature is canceled by Tomorro, the contract goes back to `negotiating` and the sender receives the usual "signature canceled" email asking to send again. No `signature.canceled` reaches you, since the webhook is disabled by then. If your tool did create the envelope anyway (answer later than 3 s), you can still push the signed PDFs on `POST /v2/contracts/{id}/signed-version`, which accepts a contract in `negotiating`.
* **Events fired while the webhook is disabled are dropped**, not queued and not replayed on re-enable. A signature canceled in Tomorro during that window never reaches your endpoint: after re-enabling, reconcile the contracts you had in `signing`.
* **Idempotency**: a retry resends the same `eventId`. Deduplicate on `eventId`, an event can legitimately arrive several times.
#### 3.2.4 - Test fires from the admin console
The **Send a test** buttons in the admin console fire a **real, signed delivery** at your URL: same envelope, same `Leeway-Signature` header, same 3 s timeout. The body is synthetic: its ids name **no real contract**, so every API call made with them is rejected.
A test fire is recognizable by one extra top-level field, which never appears on a genuine event:
```json theme={null}
{
"eventId": "7f3c1a02-9d4e-4b16-8c5a-21e0f6b8d734",
"webhookId": "",
"createdAt": "2026-09-21T09:19:12.921Z",
"eventType": "signature.requested",
"test": true,
"data": {
"organizationId": "",
"contractId": "c41b7e58-2a6d-4f93-b0e7-5d8c39a17b62",
"signatureId": "5a0e9c73-1d4b-4f28-9b6e-83c2f7d41a06",
"contractName": "Test contract",
"counterparty": { "id": "9e2d4f81-6b35-47ac-a91f-0c7b52e8d413", "name": "Test counterparty" },
"signatories": [
{
"id": "2b54acdd-5f44-4245-845e-4a3a1d346724",
"name": "John Doe",
"email": "john.doe@external.com",
"order": null,
"type": "guest",
"title": "Legal Counsel"
},
{
"id": "cd9e52bf-34e1-45cc-ba00-1028b2244a3f",
"name": "Jane Doe",
"email": "jane.doe@internal.com",
"order": null,
"type": "member",
"title": "CEO"
}
],
"contractMembers": [
{ "id": "3d8a61f4-5c07-4e2b-9a34-7f1b0d6e59c8", "email": "jane.doe@internal.com" },
{ "id": "bd9e52bf-39e1-45cc-ba00-1024b2244a3f", "email": "richard.roe@internal.com" }
],
"signatureNote": "Test signature note"
}
}
```
**Branch on `test`: verify the signature, answer 2xx, and stop there.**
The `signature.canceled` test is the same body with `"eventType": "signature.canceled"` and `data.cancelReason`. Tests are not retried and do **not** count towards the 10 failures that disable the webhook (3.2.3).
### 3.3 - Enum and field reference
| Field | Values | Note |
| - | - | - |
| `eventType` | `signature.requested` , `signature.canceled` | Only these two for this integration. |
| `data.status` on `GET /v2/contracts/{id}` | `draft` , `negotiating` , `signing` , `signed` , `canceled` | `signing` while the signature runs in your tool. A canceled signature sends the contract back to `draft` or `negotiating`, not to `canceled`. |
| `signatories[].type` | `member` , `guest` | `member` = internal party, `guest` = external party. |
| `signatories[].order` | integer or `null` | Rank among the internal signatories (`member`), **starting at 0**. Always `null` on guests, who are never ranked among themselves, and on everyone when the signature is not ordered. Which side signs first is carried by `firstSigningParty`. |
| `signatories[].name` | string | **Identify a signatory by `email`, never by `name`.** `name` can be `""` when a guest was invited by email and has not named itself yet, on the webhook and on `signing-files` alike. |
| `signatories[].title` | string or `null` | The signatory's job title, `null` when not filled in. |
| `signatureNote` | string or `null` | The note typed by the sender. `null` when no note was written. |
| `signatureId` | string (UUID) | The Tomorro signature the event is about. Same value on the `signature.requested` and the `signature.canceled` of one ceremony; a new one after a cancel and re-send. |
| `cancelReason` | string or `null` | **Only on `signature.canceled`.** The reason typed by whoever canceled, `null` when none was given. |
| `firstSigningParty` | `member` , `guest` , `null` | Which side signs first. `member` = internal party, `guest` = external party. `null` = when the signature is not ordered. |
| `files[].fields[].type` | `signature` , `initials` | **Different axis from `signatories[].type`.** Both are named `type`, one says who, the other says what kind of mark. |
| `files[].pages[]` | `{ width, height }` | Page dimensions in points, one entry per page in page order, to convert the percentages into absolute coordinates. Floats, not integers (e.g. `595.303937007874`). |
| `files[].fields[].positionX` / `positionY` | float | Percentage (0 to 100) of the page width and height. It locates the **top-left corner of the field**, measured from the top-left corner of the page. In PDF points: `x = positionX / 100 × pages[pageIndex].width`, same for `y` with `height`. The origin is the **top-left** corner and `y` grows downwards. If your tool measures from the **bottom-left** (native PDF coordinates), convert: `y = height − y`, minus the field height if your tool positions a field by its bottom-left corner. No field width or height is sent: use the default field size of your tool. |
| `files[].fields[].pageIndex` | integer | **0-based**, `0` is the first page. |
**Accepted file types**
For `files/upload-url` and `attachments` (Flow 5). The `name` must end with the extension on the same row, and the same `contentType` is sent in the three steps. `signed-version` (Flow 2) only accepts PDF.
| File | `name` ends with | `contentType` |
| - | - | - |
| **PDF** | `.pdf` | `application/pdf` |
| **Word** | `.docx` | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` |
| **Excel** | `.xlsx` | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` |
> **Other accepted types**
>
> | File | `name` ends with | `contentType` |
> | - | - | - |
> | Word (legacy) | `.doc`, `.rtf` | `application/msword` |
> | Excel (legacy) | `.xls` | `application/vnd.ms-excel` |
> | Excel with macros | `.xlsm` | `application/vnd.ms-excel.sheet.macroenabled.12` |
> | PowerPoint | `.pptx` | `application/vnd.openxmlformats-officedocument.presentationml.presentation` |
> | PowerPoint (legacy) | `.ppt` | `application/vnd.ms-powerpoint` |
> | Outlook email | `.msg` | `application/vnd.ms-outlook` |
> | OpenDocument text | `.odt` | `application/vnd.oasis.opendocument.text` |
> | OpenDocument spreadsheet | `.ods` | `application/vnd.oasis.opendocument.spreadsheet` |
> | OpenDocument presentation | `.odp` | `application/vnd.oasis.opendocument.presentation` |
> | OpenDocument drawing | `.odg` | `application/vnd.oasis.opendocument.graphics` |
> | CSV | `.csv` | `text/csv` |
> | Text | `.txt` | `text/plain` |
> | JPEG image | `.jpg`, `.jpeg` | `image/jpeg` |
> | PNG image | `.png` | `image/png` |
> | HEIC / HEIF image | `.heic` / `.heif` | `image/heic` / `image/heif` |
> | TIFF image | `.tiff`, `.tif` | `image/tiff` |
> | WebP image | `.webp` | `image/webp` |
### 3.4 - Error handling
Every endpoint follows the standard error format of the REST API, no specific contract for this integration.
**Error envelope**
```json theme={null}
{
"error": {
"statusCode": "409",
"errorId": "optional, present on some domain errors",
"message": "Human readable message",
"details": [
{ "field": "files.0.contentType", "message": "..." }
]
},
"meta": {
"timestamp": "2026-09-08T10:09:22.634Z",
"requestId": "req_Ab12Cd34"
}
}
```
* `error.statusCode` is a **string**, not a number.
* `error.details` is only present on validation errors, one entry per offending field.
* `meta.requestId` is the value to quote to Tomorro support when reporting a failing call. Log it. It is also sent in the `x-request-id` response header.
**Status codes (summary)**
Details per endpoint are in Flows 2, 4 and 5.
| Code | Meaning | Retry? |
| - | - | - |
| `400` | Invalid body (payloads are strict, unknown fields are refused), or contract not in `signing` on `cancel-signatures`. | No |
| `401` | Missing or unknown `x-api-key`. | No |
| `403` | No permission, or contract missing or not visible to the automation account. Indistinguishable, on purpose. | No |
| `404` | Only on `GET /v2/contracts/{id}` (`ContractNotFound`). | No |
| `409` | State conflict. Branch on `error.errorId` (`ContractAlreadySigned` = success). On `signing-files`, no `errorId`: read `error.message`. | No |
| `429` | Rate limit exceeded (40 calls per minute). | Yes, with backoff |
| `500` / timeout | Internal error. | Yes, with backoff. Once only on `attachments` (Flow 5). |
### 3.5 - Tomorro REST API Collection
> **[Get the Tomorro OpenAPI collection](https://drive.google.com/drive/folders/1YXxdcyQ7cBKlkSu5FGwl0nZx5Vk5-t0U)**
>
> `tomorro-openapi.json`
# Non-Technical scoping
Source: https://alpha.developer.tomorro.com/guides/create-contracts-from-your-hris/non-technical-scoping
Create contracts from your HRIS: what the business owner decides, and the brief to hand to IT
**Goal:** a new employee is created in your HRIS (Lucca, Workday, Personio, HiBob, PayFit...), and their employment contract is ready in Tomorro: filled in with the employee's data and already sent for signature. Nobody types it twice, nobody forgets to send it.
**TL;DR:** one flow, plus an optional one, run by a no-code tool (Make, n8n) or a few lines of code.
* **Flow 1: New employee in the HRIS.** Tomorro creates the employment contract from your template, with the employee's data (name, date of birth, salary, job title, grade...). The employee is added as the external signatory, your internal signatory is already set on the template, and the contract goes straight to signature.
* **Flow 2 (optional): Contract signed.** The signed PDF and the signature date go back to the employee's record in the HRIS.
**For the business owner.** A few decisions, no technical skills needed. Fill in the brief below and send it to your IT team or integrator: with the [Technical recipes](/guides/create-contracts-from-your-hris/technical-recipes), it has everything they need.
## Brief for your IT team
Copy this brief, fill in the blanks, and send it to your IT team or integrator. **It is all they need from you.**
```markdown Brief for IT theme={null}
Subject: Create contracts from the HRIS x Tomorro — ready to build
Hi,
I have done the business part of the HRIS x Tomorro contract creation automation. Over to you:
everything you need to build it is below.
1. Scope
- HRIS: ______________________ (e.g. Lucca)
- Employees in scope: ______________________ (e.g. new permanent hires of the French entity)
- Tomorro contract type: ______________________ (e.g. Employment contract)
- Template(s): ______________________ (e.g. one per job: Sales, Engineering, Operations)
- Contract language(s): ______________________ (e.g. French, English for non-French speakers)
- Internal signatory (set on the template): ______________________ (e.g. the HR Director)
- Send the signed contract back to the HRIS (Flow 2): yes / no
2. Data mapping (HRIS field -> Tomorro field)
- ______________________ -> External signatory email (required)
- ______________________ -> ______________________
- ______________________ -> ______________________
- ______________________ -> ______________________
- ______________________ -> ______________________
- New HRIS field "Tomorro contract" -> filled by the integration
3. Timing
- Flow 1 - New employee in the HRIS: ______________________ (e.g. as soon as HR validates the employee record)
- Flow 2 - Contract signed: ______________________ (e.g. as soon as the employee has signed)
4. Done on my side
- [ ] Contract type, template(s) and smart fields ready in Tomorro, internal signatory set
- [ ] Approval before signature: none on this contract type / approval kept (the contract waits for it)
Your part (all in the technical recipes):
- Technical recipes: https://alpha.developer.tomorro.com/guides/create-contracts-from-your-hris/technical-recipes
- API key: Tomorro > Settings > Integrations > API key, from a dedicated superadmin account
Thanks!
```
## Help to complete the brief
### 1 - Scope
| Question | Example answer | Why it matters |
| - | - | - |
| Which employees get a contract from Tomorro? | New permanent hires of the French entity | Only these trigger the automation |
| When is an employee record ready? | When HR validates it in the HRIS | The contract is only as good as the data it starts from |
| One template or one per job? | One per job | The integration picks the template from the job title |
| In which language(s)? | French, and English for non-French speakers | The language comes with the template: one template per language |
| Who signs for the company? | The HR Director, set on the template | Nothing to send for the internal side |
| Send the signed contract back to the HRIS? | Yes, in the employee's documents | If not, Flow 2 is not needed |
### 2 - Data mapping
One line per employee field you want in the contract.
> [!NOTE]
>
> Example only: list your own HRIS fields and the Tomorro fields they should fill.
| HRIS field | Tomorro field | Rule |
| - | - | - |
| Last name | Last name | Copied as is |
| First name | First name | Copied as is |
| Date of birth | Date of birth (date) | Copied as is |
| Email | External signatory | Required: the employee receives the contract to sign |
| Salary | Gross annual salary (number) | Copied as is |
| Job title | Job title (list) | Same list of values in both tools. Also picks the template |
| Preferred language | - | Picks the template in that language |
| Grade | Grade (list) | Same list of values in both tools |
| Tomorro contract | - | New HRIS field, filled automatically |
### 3 - Timing
When should each automation run, and what starts it?
> [!NOTE]
>
> Example only: choose the timing that fits your process.
| Flow | What starts it | When it runs |
| - | - | - |
| Flow 1 - New employee in the HRIS | Every employee record validated by HR | As soon as the record is validated in the HRIS |
| Flow 2 - Contract signed (optional) | Every employment contract signed in Tomorro | As soon as the employee has signed |
### 4 - Checklist
* [ ] Scope (1), mapping (2) and timing (3) validated
* [ ] Contract type and template(s) ready in Tomorro, one per language needed: one smart field per mapped data (**Libraries > Dynamic components > Smart fields**), the same list values as the HRIS, the internal signatory set and an empty signature zone for the employee
* [ ] Approval before signature decided for this contract type: none, or kept (the contract then waits for it before going to signature)
* [ ] An IT contact or integrator to build the flows, and the brief sent to them
# Technical recipes
Source: https://alpha.developer.tomorro.com/guides/create-contracts-from-your-hris/technical-recipes
Create contracts from your HRIS: prerequisites, the flows and the API reference for your IT team
Everything runs on the Tomorro REST API: one counterparty call, one contract call and one signature call per new employee. How you read and write the HRIS depends on your HRIS.
```mermaid theme={null}
sequenceDiagram
autonumber
actor U as HR user
participant H as HRIS
employee records
participant I as Integration
customer middleware
participant T as Tomorro
actor E as Employee
Note over U,E: Prerequisites: "automation" superadmin account, API key, contract type + template (smart fields, internal signatory, empty employee signature zone), "Tomorro contract" field in the HRIS
rect rgb(232, 244, 253)
Note over U,E: Flow 1: New employee in the HRIS (HRIS -> Tomorro -> HRIS)
U->>H: Creates and validates the employee record
H-->>I: New employee (event, webhook or polling)
I->>T: POST /v2/counterparties
name = first name + last name
T-->>I: 201 data.id
I->>T: POST /v2/contracts
contractTypeId + templateId + counterparty.id
fields + external signatory email
T-->>I: 201 data.id + contractUrl
I->>T: POST /v2/contracts/{id}/request-signatures
T-->>I: 200 data.status = sent
T->>E: Signature request email
I->>H: Write the contract id and contractUrl into "Tomorro contract"
end
rect rgb(245, 229, 164)
Note over U,E: Flow 2 (optional): Contract signed (Tomorro -> HRIS)
E->>T: Signs
Note over T: Internal signatory signs (order set on the template)
T->>I: POST webhook contractSigned
I-->>T: 200 OK
I->>T: GET /v2/contracts/{id}/signed-files
T-->>I: downloadUrl (valid 30 s)
I->>H: Upload the signed PDF + signature date to the employee record
end
```
## 1. Technical prerequisites
**1. 1.1 - Create Tomorro superadmin account**
An existing admin creates a dedicated **superadmin** account from **Settings > Members** (e.g. `automation@customer.com`), not a personal account.
* The API key is bound to a **member**, not to the organization. Every write performed through the API is attributed to that member and inherits its permissions.
* Consequence: this account is the author of every employment contract created by the integration. A named account such as "HRIS automation" keeps the contract timeline readable.
* Recommended: no personal mailbox behind it, and exclude it from Tomorro notification settings.
**2. 1.2 - API key**
Generate the API key from that superadmin account. It does not expire. It is sent on every REST call in the `x-api-key` header: [Step 1: Get your API key](/quickstart)
**3. 1.3 - Contract type and template in Tomorro**
The template carries everything the integration does not send.
* **Contract type** (e.g. *Employment contract*): its **default signature tool is Tomorro**, so the employee receives the signature email from Tomorro.
* **Smart fields**: one per mapped HRIS field (**Libraries > Dynamic components > Smart fields > New**), placed in the template. Pick the type matching the HRIS: *Text* for names, *Date* for the date of birth, *Number* for the salary, *Single-choice selector* for the job title and the grade (options = the HRIS values, see 3.3).
* **Signatories on the template**: the **internal signatory** is set on the template (e.g. the HR Director), and the **external signatory** has a signature zone but no one assigned. The integration fills that empty slot with the employee's email.
* **One template per job** if the contract differs by job: keep a `job title → templateId` table in the integration.
* **One template per language**: the contract takes the **language of its template** (`language` in `GET /v2/contract-types/{id}/templates`, e.g. `FR`, `EN`). To generate a contract in English, create the English template and pick it: the table becomes `job title + language → templateId`.
* Copy the ids next to your mapping ([Non-Technical scoping](/guides/create-contracts-from-your-hris/non-technical-scoping)): the contract type id from `GET /v2/contract-types`, the template ids from `GET /v2/contract-types/{id}/templates`, the smart field ids from `GET /v2/contract-types/{id}/creation-form` (or the last segment of the smart field URL).
| Mapping | API name |
| - | - |
| Contract type | `contractTypeId` |
| Template (per job and language) | `templateId` |
| Last name | `3f2b8c1e-...` (smart field id) |
| First name | `8a41d0f7-...` |
| Date of birth | `c9e07b52-...` |
| Email | `signatories.externalSignatoryEmailList` |
| Salary | `51d6a3e9-...` |
| Job title | `e7b94f20-...` (option label, see 3.3) |
| Grade | `0c5f8d16-...` (option label, see 3.3) |
| Tomorro contract | `id` and `contractUrl` (response) |
**4. 1.4 - Approval before signature**
If an approval workflow runs **before signature** on this contract type, the contract goes to approval first and is not sent to the employee straight away (Flow 1, step 3). Decide with the business owner: no approval on this contract type, or keep it and let the contract wait for it.
**5. 1.5 - HRIS: add the Tomorro contract field**
Add a **text** field `Tomorro contract` to the employee record, empty for every employee. The integration fills it with the contract id (and `contractUrl` if there is room); users must not edit it. It stops a second contract from being created for the same employee.
## 2. Flows
### Flow 1 - \[HRIS → Tomorro → HRIS] New employee in the HRIS
**1. Step 0 - Triggered by the HRIS**
Out of scope. An employee record is created and validated in the HRIS: HRIS event or webhook if it has one, otherwise poll the employees created since the last run. Skip it if `Tomorro contract` is already filled, or if a mapped field or the email is empty.
**2. Step 1 - Create the employee as a counterparty**
```http theme={null}
POST https://api.tomorro.com/v2/counterparties
x-api-key:
Content-Type: application/json
{ "name": "Jane Doe" }
```
Response `201`:
```json theme={null}
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Jane Doe",
"status": "active"
}
}
```
> **Notes**
>
> * Keep `data.id` for step 2.
> * **Why a separate call**: `POST /v2/contracts` also accepts `counterparty.name`, but it then **reuses** any counterparty with the same name (case and spaces ignored). Two employees named Jane Doe would share one counterparty. Creating it here, then passing its `id`, gives every employee their own.
**3. Step 2 - Create the employment contract**
```http theme={null}
POST https://api.tomorro.com/v2/contracts
x-api-key:
Content-Type: application/json
{
"contractTypeId": "6f1c2d3e-...",
"templateId": "9a8b7c6d-...",
"name": "Employment contract - Jane Doe",
"counterparty": { "id": "550e8400-e29b-41d4-a716-446655440000" },
"fields": [
{ "fieldId": "3f2b8c1e-...", "value": "Doe" },
{ "fieldId": "8a41d0f7-...", "value": "Jane" },
{ "fieldId": "c9e07b52-...", "value": "1994-03-12" },
{ "fieldId": "51d6a3e9-...", "value": "48000" },
{ "fieldId": "e7b94f20-...", "value": "Account Executive" },
{ "fieldId": "0c5f8d16-...", "value": "Level 2" }
],
"signatories": {
"externalSignatoryEmailList": ["jane.doe@example.com"]
}
}
```
Response `201`:
```json theme={null}
{
"data": {
"id": "7d12db6f-36a6-4dc5-b022-66471ff5xxxc",
"name": "Employment contract - Jane Doe",
"status": "draft",
"templateId": "9a8b7c6d-...",
"signatories": [ "... the internal signatory of the template, and jane.doe@example.com ..." ],
"contractUrl": "https://app.tomorro.com/acme/project/7d12db6f-36a6-4dc5-b022-66471ff5xxxc"
}
}
```
> **Notes**
>
> * **`templateId` is required in practice**: without it, the contract has no document and cannot be sent for signature. It also sets the contract **language**.
> * **`fields`**: one entry per smart field, `value` always a **string**. Dates as `YYYY-MM-DD`, numbers without spaces or currency (`"48000"`), picklists as the **exact option label** (see 3.3). Leave a field out rather than sending an empty string.
> * **Signatories**: only send the employee's email. It fills the **empty external signature zone** of the template; the internal signatory is already on the template. Do not add more signatories than the template has empty zones: the extra ones get no signature zone and step 3 is refused.
> * Required fields of the contract type's creation form that are missing: `400 IncompleteCreationForm`. Unknown `contractTypeId` or `templateId`: `404`.
> * Keep `data.id` and `data.contractUrl`.
**4. Step 3 - Send the contract for signature**
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{contractId}/request-signatures
x-api-key:
Content-Type: application/json
{
"signatureNote": "Welcome aboard! Here is your employment contract.",
"isReminderEnabled": true
}
```
Response `200`:
```json theme={null}
{
"data": {
"id": "a1b2c3d4-...",
"status": "sent",
"signatories": [ "..." ],
"sentAt": "2026-10-02T09:15:00.000Z",
"contractUrl": "https://app.tomorro.com/acme/project/7d12db6f-36a6-4dc5-b022-66471ff5xxxc"
}
}
```
> **Notes**
>
> * Can be called **right after step 2**: the document and its signatories are ready when step 2 answers.
> * `documentId` is optional: without it, the last document of the contract is sent.
> * **Always check `data.status`**:
> * `sent`: done. The contract moves to `signing`, Tomorro emails the signatories in the signing order of the contract type and template.
> * `draft`: the signature was **not** sent. Usually an approval workflow before signature (1.4): the contract waits for it. Alert HR with `contractUrl`.
> * `signatureNote` is the message shown to the signatories. `isReminderEnabled` defaults to `true`.
>
> **Errors on step 3** (`400`, branch on `error.errorId`)
>
> | `errorId` | Meaning | What to do |
> | - | - | - |
> | `StartSignatureBlankSignatory` | A signature zone of the template has no one assigned (email missing in step 2, or a second empty zone). | Fix the template or the email, then retry. |
> | `StartSignatureMissingFields` | A signatory has no signature zone: more signatories sent in step 2 than empty zones on the template. | Fix the template, recreate the contract. |
> | `StartSignatureMissingSignatory` | No signatory at all on the contract. | Check the template signatories (1.3). |
> | `StartSignatureMissingDocumentSmartFieldsForParty` | A smart field of the document is still empty. | Send it in step 2, or fill it from `contractUrl`. |
> | `ContractNotReadyToPrepareSignature` | The contract cannot be sent as is: approval pending, pending suggestions, document already sent... | Open `contractUrl` and finish by hand. |
**5. Step 4 - Write the contract back to the HRIS**
Out of scope (depends on the HRIS). Write `data.id` (and `contractUrl`) from step 2 into `Tomorro contract`, straight away: until it is written, a new run would create a second contract for the same employee.
> [!TIP]
>
> **Rate limit**: no need to slow the calls down. If Tomorro answers `429`, retry with backoff.
### Flow 2 (optional) - \[Tomorro → HRIS] Contract signed
**1. Step 0 - Receive the contractSigned webhook**
Create the webhook **from the automation account** (1.1), trigger `contractSigned`: [Webhooks](/webhooks/overview). Webhooks only fire for contracts that concern the member who created them, and this account is the author of every contract of Flow 1.
Verify the `Leeway-Signature` header ([Technical documentation](/webhooks/technical-documentation)), answer `2xx` straight away, then keep `data.contract.id`, and `data.contract.signatureDate` for the HRIS.
**2. Step 1 - Find the employee**
Out of scope (depends on the HRIS). Look up the employee whose `Tomorro contract` field holds `data.contract.id` (Flow 1, step 4). No employee: the contract was not created by Flow 1, ignore the event.
**3. Step 2 - Get the signed PDF**
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}/signed-files
x-api-key:
```
Response `200`:
```json theme={null}
{
"data": [
{
"id": "550e8400-...",
"filename": "Employment contract - Jane Doe - signed.pdf",
"contentType": "application/pdf",
"downloadUrl": "https://s3...",
"expiresAt": "2026-10-03T10:45:30.000Z",
"contractUrl": "https://app.tomorro.com/acme/project/7d12db6f-..."
}
]
}
```
`downloadUrl` is valid **30 seconds**: download the file straight away (no auth header), and re-call the endpoint for a fresh URL if needed.
**4. Step 3 - Upload to the employee record**
Out of scope (depends on the HRIS). Upload the PDF to the employee's documents and write the signature date. With Lucca, for instance, attach the uploaded file to the work contract by its `fileId`.
## 3. Technical references
Reference material: conventions, the endpoints, field values and error handling. Read it once, then come back to it while implementing the flows above.
### 3.1 - API conventions
* **REST only.** The whole integration runs on the public REST API.
* **Base URL**: `https://api.tomorro.com/v2`
* **Authentication**: `x-api-key: ` on every call. A missing or unknown key returns 401.
* **Versioning**: optional `tomorro-version` header, defaults to the latest version.
* **Rate limit**: a `429` means too many calls in a short time. Retry with backoff.
* **Sandbox**: available on request. It is an empty sandbox, not a copy of your production workspace.
* **Content type**: `application/json`
* **Response envelope**: every **successful** JSON response is wrapped in a `data` object (errors come as `{ error, meta }`, see 3.4). Read `data.id`, `data.status` and so on.
* **Full reference**: [Tomorro API](/api-reference/introduction)
### 3.2 - Endpoints
| Endpoint | Use in this integration |
| - | - |
| `GET /v2/contract-types` | Setup: the contract type id. |
| `GET /v2/contract-types/{id}/templates` | Setup: the template ids, their `language` and their smart fields. |
| `GET /v2/contract-types/{id}/creation-form` | Setup: the smart fields of the creation form, and which are `required`. |
| `POST /v2/counterparties` | Flow 1, step 1: the employee as a counterparty. Returns `201`. |
| `POST /v2/contracts` | Flow 1, step 2: the contract, its document, its fields and the employee as signatory. Returns `201`. |
| `POST /v2/contracts/{id}/request-signatures` | Flow 1, step 3: send for signature. Returns `200` with `data.status`. |
| `GET /v2/contracts?counterpartyId={id}` | Recovery: the contracts of an employee, to check a creation went through (3.4). |
| `GET /v2/contracts/{id}/signed-files` | Flow 2: the signed PDF. |
### 3.3 - Field values
`fields[].value` is always a string. Tomorro does not reformat it: send it in the format below.
| Smart field type | Send | Example |
| - | - | - |
| Text | The text | `"Doe"` |
| Date | `YYYY-MM-DD` | `"1994-03-12"` |
| Number | Digits, `.` for decimals, no spaces or currency | `"48000"` |
| Single-choice selector | The **exact option label**, case included | `"Account Executive"` |
| Multi-choice selector | Exact labels separated by `;` | `"French;English"` |
* **Picklists**: a label that matches no option is not refused, it is stored as an unknown value and shows empty in Tomorro. Align the labels on both sides first (job titles, grades), and translate HRIS codes into Tomorro labels in the integration when they differ.
* **Built-in contract fields** can be set the same way, by name instead of id: `startAt` (start date, `YYYY-MM-DD`), `endAt` (end date, for a fixed-term contract).
* `null` clears a field.
### 3.4 - Error handling
Every endpoint follows the standard error format of the REST API.
**Error envelope**
```json theme={null}
{
"error": {
"statusCode": "400",
"errorId": "optional, present on some domain errors",
"message": "Human readable message",
"details": [
{ "field": "fields.0.value", "message": "..." }
]
},
"meta": {
"timestamp": "2026-10-02T10:09:22.634Z",
"requestId": "req_Ab12Cd34"
}
}
```
* `error.statusCode` is a **string**, not a number.
* `meta.requestId` is the value to quote to Tomorro support when reporting a failing call. Log it.
| Code | Meaning | Retry? |
| - | - | - |
| `400` | Invalid body (unknown key, wrong type), `IncompleteCreationForm` on contract creation, or a signature blocker on `request-signatures` (see Flow 1, step 3). | No, fix the payload or the template |
| `401` | Missing or unknown `x-api-key`. | No |
| `403` | The API key's account is not allowed to create contracts of this type or to manage counterparties. | No, check 1.1 |
| `404` | Unknown contract type, template or contract. | No |
| `429` | Rate limit exceeded. | Yes, with backoff |
| `500` / timeout | Internal error. | Yes, with backoff. On a contract creation, check first that it was not created (`GET /v2/contracts?counterpartyId=` the id of step 1) to avoid a duplicate. |
### 3.5 - Edge cases
* **Link**: the `Tomorro contract` field in the HRIS. Never match on the employee's name.
* **Employee created twice in the HRIS, or flow re-run**: skipped as long as `Tomorro contract` is filled (Flow 1, step 0).
* **Rehire**: a returning employee already has a counterparty. Reuse its id (store it in the HRIS too) instead of creating a second one in step 1.
* **Missing email**: no external signatory, the contract cannot go to signature. Skip the employee in step 0 and alert HR.
* **Data changed in the HRIS after the contract was sent**: the integration does not update a contract in signature. Cancel it in Tomorro and clear `Tomorro contract` to generate a new one.
* **Triggers**: prefer an HRIS webhook or event on "employee validated", so Flow 1 runs as soon as the record is ready. Without one, poll the employees created since the last run, e.g. every 15 minutes.
# Guides
Source: https://alpha.developer.tomorro.com/guides/overview
Step-by-step guides for the integrations teams build with the Tomorro Public API
The Tomorro Public API lets you exchange data between Tomorro and the tools your teams already use: CRM, ERP, data warehouse, automation platform or internal app.
Each guide below covers one integration end to end:
* **For the business owner**: what the integration does, the few decisions to make, and a short checklist. No technical skills needed.
* **For IT**: the flows, the exact API calls, and a working example built with a no-code tool.
> **[Sync your counterparties](/guides/sync-your-counterparties/non-technical-scoping)**
>
> Keep the counterparties in your ERP and in Tomorro in sync, with no manual entry.
> **[Connect your own signature tool](/guides/connect-your-own-signature-tool/non-technical-scoping)**
>
> Send Tomorro contracts for signature in Yousign, Universign, Adobe Sign or any other tool, and get the signed PDF back.
> **[Create contracts from your HRIS](/guides/create-contracts-from-your-hris/non-technical-scoping)**
>
> A new employee in Lucca, Workday or Personio, and their employment contract is created and sent for signature.
> **[Back up your signed contracts](/guides/backup-your-signed-contracts/non-technical-scoping)**
>
> Every signed contract saved as a PDF in SharePoint, Google Drive, Box or S3, as soon as it is signed.
## Scope your own integration
Start from the end goal (*what should this automation achieve?*) and describe it as a simple **Trigger → Action → Data** flow:
* **Trigger**: the event that starts the automation, in Tomorro or in your tool.
* **Action**: what should happen as a result, in the other system.
* **Data**: the fields exchanged, and in which direction.
Need help with a use case not listed here? [Contact support](mailto:support@tomorro.com).
# Full cycle example
Source: https://alpha.developer.tomorro.com/guides/sync-your-counterparties/full-cycle-example
Sync your counterparties: a working example with a Notion database as the ERP and four Make scenarios
In this example, we build the whole integration with [Make](https://www.make.com) as the middleware, and a Notion database playing the role of the ERP, the counterparty base. Each of the four flows is one Make scenario: you can watch it run end to end in the video, then import the same scenarios from the [blueprints](#make-blueprints).
[Counterparty sync demo: Notion and Make](https://www.loom.com/embed/19dcfc6b90a142788f0bdd6073c7c0ca)
## Notion ERP with Make
[Make](https://www.make.com) is a no-code automation platform. The four Make scenarios below run every flow of this doc with a Notion database playing the ERP (*Mock Counterparties*: `Counterparty Name`, `SIRET Number`, `Industry`, `Address`, `City`, `ID tomorro`). Use them as a **head start** if you run Make, or as a reference implementation for n8n, custom code or another ERP: only the ERP modules change.
> **Notes**
>
> The scenarios are a working example, not a ready-made integration. They make choices you may want to change:
>
> * **Connections**: the Tomorro calls use an *API Key Auth* keychain (header `x-api-key`), the Notion calls a Notion connection invited on the counterparty database.
> * **Notion calls** go through *Make an API Call* (Notion API version `2022-06-28`), so the raw Notion JSON is parsed in code modules. With your ERP, replace these modules with its own connector.
> * **Webhooks**: Flows 1 and 2 start from a Notion webhook (*Watch Events*, event *Page: Properties updated*), within a minute or two of the change: Notion groups its events before sending them. No polling. The webhook receives events from the whole Notion workspace: pages the connection cannot read are skipped by an *Ignore* error handler, and a filter keeps only the counterparty database.
> * **Retries**: the Tomorro calls that write (create, update) retry automatically, 3 times, 1 minute apart. A bundle that still fails is kept as an incomplete execution, to review in Make.
> * **Sync state**: Flow 3 saves the last `updatedAt` it processed in a Make data store, and only reads what changed since.
> * **Pages**: Flow 0 reads up to 100 counterparties per run and can simply be re-run. Flow 3 reads the 50 latest updates per run; paginate with `pagination.next_cursor` above that.
> * **Industry**: labels are matched case-insensitively on the Tomorro options. A Notion value missing from Tomorro is left out and reported in the `warning` output of the code module.
### About the code below
The scenarios are mostly standard modules. The logic lives in a few short **JavaScript** snippets, run by Make *Code* modules: read the ERP row, translate the Industry label into its option id, and compare both sides before writing. They are already inside the [blueprints](#make-blueprints): you only need to copy them if you build the flows yourself.
| You use | How to reuse the snippets |
| - | - |
| **Make** | Nothing to copy: import the [blueprints](#make-blueprints). Open a Code module to adapt a field. |
| **n8n** | Paste them into a *Code* node. Replace `input` with the node input (`$json`) and return the result. |
| **Custom code** | Wrap each snippet in a function: `input` is its argument, the returned object is its result. |
| **Another ERP** | Only the reading part changes: replace the Notion property names (`p['Counterparty Name']`, `p['SIRET Number']`…) with your ERP fields. |
In every case, replace the smart field ids in `FIELDS` with yours (see [1.3](/guides/sync-your-counterparties/technical-recipes#1-3-counterparty-smart-fields-in-tomorro)).
### Flow 0 - \[Notion → Tomorro → Notion] Initialisation
Make scenario *Flow 0 - Initialisation*, scheduling **on demand** (Run once).
**1. Step 0 - List the counterparties not linked to Tomorro**
*Make modules 1, 3*
Query the database for rows with an empty `ID tomorro`, then iterate over `body.results`:
```http theme={null}
POST https://api.notion.com/v1/databases/{databaseId}/query
Notion-Version: 2022-06-28
{ "filter": { "property": "ID tomorro", "rich_text": { "is_empty": true } }, "page_size": 100 }
```
**2. Step 1 - Get the Industry option ids**
*Make module 2*
```http theme={null}
GET https://api.tomorro.com/v2/smart-fields/b25c969a-2a1c-4903-be86-10269aeb5e84/options?limit=50
x-api-key:
```
**3. Step 2.1 - Build the counterparty body**
*Make module 4*
Inputs: the row `id` and `properties`, and `data` of step 1. Skips rows already linked or without a name, and translates the Industry label into its option id.
```javascript theme={null}
const { pageId, properties, industryOptions } = input;
const p = properties || {};
const text = (prop) => ((prop && (prop.title || prop.rich_text)) || []).map((t) => t.plain_text).join('').trim();
const counterparty = {
tomorroId: text(p['ID tomorro']),
name: text(p['Counterparty Name']),
siret: text(p['SIRET Number']),
industry: (p['Industry'] && p['Industry'].select && p['Industry'].select.name) || '',
address: text(p['Address']),
city: text(p['City']),
};
// Already linked, or row created empty in the ERP: nothing to create yet
if (counterparty.tomorroId) return { skip: true, reason: 'already linked to Tomorro', pageId };
if (!counterparty.name) return { skip: true, reason: 'missing counterparty name', pageId };
// Tomorro smart field ids: Dynamic components > Smart fields, last segment of the URL
const FIELDS = {
siret: '9d6175af-86a5-497a-aa42-facfb2b8dafb',
industry: 'b25c969a-2a1c-4903-be86-10269aeb5e84',
address: '0abf8d86-02ab-4583-aea3-b730ad802c37',
city: 'a6690cab-baaa-4848-8866-a03d800049e3',
};
// Tomorro stores a picklist value as the option id, not its label: translate both ways
const optionIdByLabel = {};
const labelByOptionId = {};
for (const option of industryOptions || []) {
for (const translation of option.translations || []) {
const label = String(translation.value || '').trim();
if (!label) continue;
optionIdByLabel[label.toLowerCase()] = option.id;
if (!labelByOptionId[option.id]) labelByOptionId[option.id] = label;
}
}
const values = { ...counterparty, industry: counterparty.industry && optionIdByLabel[counterparty.industry.toLowerCase()] };
const warning = counterparty.industry && !values.industry ? `Industry "${counterparty.industry}" is not a Tomorro option` : null;
const fields = {};
for (const [key, fieldId] of Object.entries(FIELDS)) {
if (values[key]) fields[fieldId] = values[key];
}
return { skip: false, pageId, warning, body: JSON.stringify({ name: counterparty.name, fields }) };
```
**4. Step 2.2 - Create the counterparty in Tomorro**
*Make module 5*, filtered on `skip` not `true`, with a *Retry* error handler.
```http theme={null}
POST https://api.tomorro.com/v2/counterparties
x-api-key:
Content-Type: application/json
```
**5. Step 3 - Write ID tomorro back to Notion**
*Make module 6*
```http theme={null}
PATCH https://api.notion.com/v1/pages/{pageId}
Notion-Version: 2022-06-28
{ "properties": { "ID tomorro": { "rich_text": [ { "text": { "content": "" } } ] } } }
```
### Flow 1 - \[Notion → Tomorro → Notion] Counterparty created in the ERP
Make scenario *Flow 1 - Creation*, triggered by a Notion webhook.
**1. Step 0 - Notion webhook: a counterparty row is edited**
*Make modules 1, 2*
*Watch Events*, event **Page: Properties updated**, then read the raw row with `GET /v1/pages/{entity.id}`, filtered on the counterparty database.
A row added in Notion is often empty when it is created: the counterparty is ready once its fields are filled. So Flow 1 listens to property updates, and only creates a counterparty for a row with a name and no `ID tomorro`.
**2. Step 1 - Get the Industry option ids**
*Make module 3*
Same call as Flow 0 step 1.
**3. Step 2.1 - Build the counterparty body**
*Make module 4*
Same code as Flow 0 step 2.1, inputs `body.id` and `body.properties` of module 2.
**4. Step 2.2 - Create the counterparty in Tomorro**
*Make module 5*
Same call as Flow 0 step 2.2, filtered on `skip` not `true`, with a *Retry* error handler.
**5. Step 3 - Write ID tomorro back to Notion**
*Make module 6*
Same call as Flow 0 step 3.
### Flow 2 - \[Notion → Tomorro] Counterparty updated in the ERP
Make scenario *Flow 2 - Update from ERP*, triggered by a Notion webhook.
**1. Step 0 - Notion webhook: a counterparty row is edited, read it**
*Make modules 1, 2, 3*
*Watch Events*, event **Page: Properties updated**, `GET /v1/pages/{entity.id}` filtered on the counterparty database, then read the values. Rows without `ID tomorro` are left to Flow 1.
```javascript theme={null}
const { pageId, properties } = input;
const p = properties || {};
const text = (prop) => ((prop && (prop.title || prop.rich_text)) || []).map((t) => t.plain_text).join('').trim();
const counterparty = {
tomorroId: text(p['ID tomorro']),
name: text(p['Counterparty Name']),
siret: text(p['SIRET Number']),
industry: (p['Industry'] && p['Industry'].select && p['Industry'].select.name) || '',
address: text(p['Address']),
city: text(p['City']),
};
return { pageId, ...counterparty, skip: !counterparty.tomorroId || !counterparty.name };
```
**2. Step 1.1 - Read the current counterparty**
*Make module 4*, filtered on `skip` not `true` (row linked to Tomorro and named).
```http theme={null}
GET https://api.tomorro.com/v2/counterparties/{tomorroId}
x-api-key:
```
**3. Step 1.2 - Get the Industry option ids**
*Make module 5*
Same call as Flow 0 step 1.
**4. Step 2.1 - Compare Notion and Tomorro**
*Make module 6*
Inputs: the values of step 0, `data` of steps 1.1 and 1.2. Builds a `PATCH` body with the differences only. A Tomorro Industry that is not a valid option id counts as empty, so it is repaired.
```javascript theme={null}
const { name, siret, industry, address, city, counterparty, industryOptions } = input;
// Tomorro smart field ids: Dynamic components > Smart fields, last segment of the URL
const FIELDS = {
siret: '9d6175af-86a5-497a-aa42-facfb2b8dafb',
industry: 'b25c969a-2a1c-4903-be86-10269aeb5e84',
address: '0abf8d86-02ab-4583-aea3-b730ad802c37',
city: 'a6690cab-baaa-4848-8866-a03d800049e3',
};
// Tomorro stores a picklist value as the option id, not its label: translate both ways
const optionIdByLabel = {};
const labelByOptionId = {};
for (const option of industryOptions || []) {
for (const translation of option.translations || []) {
const label = String(translation.value || '').trim();
if (!label) continue;
optionIdByLabel[label.toLowerCase()] = option.id;
if (!labelByOptionId[option.id]) labelByOptionId[option.id] = label;
}
}
const current = counterparty || {};
const currentFields = current.fields || {};
const clean = (value) => (value === null || value === undefined ? '' : String(value).trim());
const tomorroValue = (fieldId) => {
const field = currentFields[fieldId];
return clean(field && typeof field === 'object' && 'value' in field ? field.value : field);
};
// Industry is compared on the option id; a Tomorro value that is not an option id is treated as empty
const erpIndustryId = clean(industry) ? optionIdByLabel[clean(industry).toLowerCase()] : '';
const tomorroIndustryId = labelByOptionId[tomorroValue(FIELDS.industry)] ? tomorroValue(FIELDS.industry) : '';
const erp = { siret: clean(siret), industry: erpIndustryId, address: clean(address), city: clean(city) };
const tomorro = {
siret: tomorroValue(FIELDS.siret),
industry: tomorroIndustryId,
address: tomorroValue(FIELDS.address),
city: tomorroValue(FIELDS.city),
};
const body = {};
if (clean(name) && clean(name) !== clean(current.name)) body.name = clean(name);
const fields = {};
for (const [key, fieldId] of Object.entries(FIELDS)) {
// ERP label missing from the Tomorro picklist: leave the Tomorro value alone
if (key === 'industry' && erpIndustryId === undefined) continue;
if (erp[key] !== tomorro[key]) fields[fieldId] = erp[key] || null;
}
if (Object.keys(fields).length) body.fields = fields;
// Nothing differs: usually the echo of Flow 1 (ID written back) or Flow 3 (Tomorro values written back)
if (!Object.keys(body).length) return { changed: false };
return { changed: true, body: JSON.stringify(body) };
```
**5. Step 2.2 - Update the counterparty in Tomorro**
*Make module 7*, filtered on `changed` = `true`, with a *Retry* error handler.
```http theme={null}
PATCH https://api.tomorro.com/v2/counterparties/{tomorroId}
x-api-key:
Content-Type: application/json
```
### Flow 3 - \[Tomorro → Notion] Counterparty updated in Tomorro
Make scenario *Flow 3 - Update from Tomorro*, every day at 8:00.
**1. Step 0 - Get the last updatedAt processed**
*Make module 1*
*Data store > Get a record*, key `flow3_last_updated_at`. Empty on the first run.
**2. Step 1.1 - List the counterparties, latest updates first**
*Make module 2*
```http theme={null}
GET https://api.tomorro.com/v2/counterparties?sort=-updatedAt&limit=50
x-api-key:
```
**3. Step 1.2 - Keep the counterparties updated since the last run**
*Make module 3*
Inputs: the saved value of step 0, `data` and `pagination.has_next` of step 1.1.
```javascript theme={null}
const { lastUpdatedAt, counterparties, hasNext } = input;
// First run: look back 24 hours
const since = lastUpdatedAt ? new Date(lastUpdatedAt) : new Date(Date.now() - 24 * 60 * 60 * 1000);
// The list is sorted by updatedAt, newest first: keep what changed since the last run
const changed = (counterparties || []).filter((c) => new Date(c.updatedAt) > since);
const newest = changed.length ? changed[0].updatedAt : since.toISOString();
const warning = hasNext && changed.length === (counterparties || []).length
? 'More counterparties changed than one page holds: paginate with pagination.next_cursor'
: null;
return { counterparties: changed, newLastUpdatedAt: newest, warning };
```
**4. Step 1.3 - Remember the latest updatedAt**
*Make module 4*
*Data store > Add/replace a record*, key `flow3_last_updated_at`, value `newLastUpdatedAt` of step 1.2.
**5. Step 1.4 - Get the Industry option ids**
*Make module 5*
Same call as Flow 0 step 1, once per run.
**6. Step 2.1 - Find the ERP counterparty**
*Make modules 6, 7*
Iterate over the counterparties of step 1.2, and look up the Notion row whose `ID tomorro` is the counterparty `id`:
```http theme={null}
POST https://api.notion.com/v1/databases/{databaseId}/query
Notion-Version: 2022-06-28
{ "filter": { "property": "ID tomorro", "rich_text": { "equals": "" } }, "page_size": 1 }
```
**7. Step 2.2 - Compare Tomorro and Notion**
*Make module 8*
Inputs: the rows of step 2.1, the counterparty `name` and `fields`, the options of step 1.4. Skips a Tomorro counterparty when no ERP row has its id, translates the Industry option id back to its label, and builds the Notion properties that differ.
```javascript theme={null}
const { rows, counterpartyName, counterpartyFields, industryOptions } = input;
// Counterparty not linked to any ERP counterparty: created in Tomorro only, ignored
const row = (rows || [])[0];
if (!row) return { changed: false, reason: 'no ERP counterparty with this ID tomorro' };
const p = row.properties || {};
const text = (prop) => ((prop && (prop.title || prop.rich_text)) || []).map((t) => t.plain_text).join('').trim();
const erp = {
name: text(p['Counterparty Name']),
siret: text(p['SIRET Number']),
industry: (p['Industry'] && p['Industry'].select && p['Industry'].select.name) || '',
address: text(p['Address']),
city: text(p['City']),
};
// Tomorro smart field ids: Dynamic components > Smart fields, last segment of the URL
const FIELDS = {
siret: '9d6175af-86a5-497a-aa42-facfb2b8dafb',
industry: 'b25c969a-2a1c-4903-be86-10269aeb5e84',
address: '0abf8d86-02ab-4583-aea3-b730ad802c37',
city: 'a6690cab-baaa-4848-8866-a03d800049e3',
};
// Tomorro stores a picklist value as the option id, not its label: translate it back
const labelByOptionId = {};
for (const option of industryOptions || []) {
for (const translation of option.translations || []) {
const label = String(translation.value || '').trim();
if (label && !labelByOptionId[option.id]) labelByOptionId[option.id] = label;
}
}
const currentFields = counterpartyFields || {};
const clean = (value) => (value === null || value === undefined ? '' : String(value).trim());
const tomorroValue = (fieldId) => {
const field = currentFields[fieldId];
return clean(field && typeof field === 'object' && 'value' in field ? field.value : field);
};
const rawIndustry = tomorroValue(FIELDS.industry);
const tomorro = {
name: clean(counterpartyName),
siret: tomorroValue(FIELDS.siret),
// undefined when the stored value is not a known option: leave the ERP alone
industry: rawIndustry ? labelByOptionId[rawIndustry] : '',
address: tomorroValue(FIELDS.address),
city: tomorroValue(FIELDS.city),
};
const richText = (value) => ({ rich_text: value ? [{ text: { content: value } }] : [] });
const toNotion = {
name: (value) => ['Counterparty Name', { title: [{ text: { content: value } }] }],
siret: (value) => ['SIRET Number', richText(value)],
industry: (value) => ['Industry', { select: value ? { name: value } : null }],
address: (value) => ['Address', richText(value)],
city: (value) => ['City', richText(value)],
};
const properties = {};
const changedKeys = [];
for (const key of Object.keys(toNotion)) {
if (tomorro[key] === undefined) continue;
if (clean(erp[key]).toLowerCase() === tomorro[key].toLowerCase()) continue;
// Counterparty Name is the Notion title: never blank it
if (key === 'name' && !tomorro.name) continue;
const [propertyName, value] = toNotion[key](tomorro[key]);
properties[propertyName] = value;
changedKeys.push(key);
}
if (!changedKeys.length) return { changed: false, pageId: row.id };
return { changed: true, pageId: row.id, changedKeys: changedKeys.join(','), body: JSON.stringify({ properties }) };
```
**8. Step 2.3 - Update the counterparty in Notion**
*Make module 9*, filtered on `changed` = `true`.
```http theme={null}
PATCH https://api.notion.com/v1/pages/{pageId}
Notion-Version: 2022-06-28
```
### Make blueprints
A blueprint is a Make scenario exported as JSON. Import it and every module comes back configured, code included.
> **[Get the 4 Make blueprints](https://drive.google.com/drive/folders/1YXxdcyQ7cBKlkSu5FGwl0nZx5Vk5-t0U)**
>
> The *Sync counterparties - Flow 0 to 3* files: Flow 0 - Initialisation, Flow 1 - Creation, Flow 2 - Update from the ERP and Flow 3 - Update from Tomorro, one JSON file each.
**1. Import**
In Make, create a scenario, open the **⋯** menu, choose **Import blueprint** and select one of the downloaded JSON files. Repeat for each flow.
**2. Connect**
* **Tomorro HTTP modules**: create an *API Key Auth* keychain: key name `x-api-key`, value your API key, placed in the header.
* **Notion modules**: select a Notion connection that has access to your counterparty database.
* **Flows 1 and 2**: on the *Watch Events* trigger, create a webhook with the event **Page: Properties updated**, one per scenario.
* **Flow 3**: on both *Data store* modules, create a data store with a `key` and a text `value`.
**3. Replace the ids**
Replace `YOUR_NOTION_DATABASE_ID` and `YOUR_NOTION_DATA_SOURCE_ID` with your database (Notion API calls and the webhook filters), and the smart field ids (URLs and `FIELDS` in the Code modules) with yours.
**4. Run**
Run Flow 0 once, check a few counterparties in Tomorro, then turn on Flows 1, 2 and 3.
# Non-Technical scoping
Source: https://alpha.developer.tomorro.com/guides/sync-your-counterparties/non-technical-scoping
Sync your counterparties: what the business owner decides, and the brief to hand to IT
**Goal:** your ERP stays the reference for counterparties. Every counterparty created or edited there shows up in Tomorro, ready to pick in any contract. Nobody types it twice.
**TL;DR:** three small flows, run by a no-code tool (Make, n8n) or a few lines of code.
* **Flow 1: Counterparty created in the ERP.**
* **Flow 2: Counterparty updated in the ERP.**
* **Flow 3: Counterparty updated in Tomorro.**

**For the business owner.** A few decisions, no technical skills needed. Fill in the brief below and send it to your IT team or integrator: with the [Technical recipes](/guides/sync-your-counterparties/technical-recipes), it has everything they need.
## Brief for your IT team
Copy this brief, fill in the blanks, and send it to your IT team or integrator. **It is all they need from you.**
```markdown Brief for IT theme={null}
Subject: ERP x Tomorro counterparty sync — ready to build
Hi,
I have done the business part of the ERP x Tomorro counterparty sync. Over to you:
everything you need to build it is below.
1. Scope
- Counterparties to sync: ______________________ (e.g. active counterparties of the French entity)
- Counterparties are created in: ______________________ (e.g. the ERP only)
- Tomorro users may correct counterparty data: yes / no (no = Flow 3 not needed)
- Same-day conflict, the winner is: ERP / Tomorro
2. Data mapping (ERP field -> Tomorro field)
- ______________________ -> Counterparty name (required)
- ______________________ -> ______________________
- ______________________ -> ______________________
- ______________________ -> ______________________
- New ERP column "ID tomorro" -> filled by the integration
3. Timing
- Flow 1 - Counterparty created in the ERP: ______________________ (e.g. as soon as the counterparty is created)
- Flow 2 - Counterparty updated in the ERP: ______________________ (e.g. as soon as the counterparty changes)
- Flow 3 - Counterparty updated in Tomorro: ______________________ (e.g. once a day, every morning at 8:00)
4. Done on my side
- [ ] Missing fields created in Tomorro, with the same list values as the ERP
Your part (all in the technical recipes):
- Technical recipes: https://alpha.developer.tomorro.com/guides/sync-your-counterparties/technical-recipes
- Ready-to-import Make scenarios: https://alpha.developer.tomorro.com/guides/sync-your-counterparties/full-cycle-example#make-blueprints
- API key: Tomorro > Settings > Integrations > API key, from a dedicated superadmin account
Thanks!
```
## Help to complete the brief
### 1 - Scope
| Question | Example answer | Why it matters |
| - | - | - |
| Which counterparties go to Tomorro? | Active counterparties of the French entity | Only these show up in contracts |
| Where are counterparties created? | In the ERP only | One source of truth, no duplicates |
| Can Tomorro users correct counterparty data? | Yes | If not, Flow 3 is not needed |
| Both sides changed the same day: who wins? | The ERP | No conflict to manage by hand |
### 2 - Data mapping
One line per counterparty field you want in Tomorro.
> [!NOTE]
>
> Example only: list your own ERP fields and the Tomorro fields they should fill.
| ERP field | Tomorro field | Rule |
| - | - | - |
| Supplier name | Counterparty name | Required |
| SIRET | Registration number | Copied as is |
| Industry | Industry (list) | Same list of values in both tools |
| Address | Address | Copied as is |
| City | City of registration | Copied as is |
| ID tomorro | - | New ERP column, filled automatically |
### 3 - Timing
When should each automation run, and what starts it?
> [!NOTE]
>
> Example only: choose the timing that fits your process.
| Flow | What starts it | When it runs |
| - | - | - |
| Flow 1 - Counterparty created in the ERP | Every counterparty created in the ERP | As soon as the counterparty is created in the ERP |
| Flow 2 - Counterparty updated in the ERP | Every change in the ERP | As soon as the counterparty changes in the ERP |
| Flow 3 - Counterparty updated in Tomorro | Every change in Tomorro | Once a day, every morning at 8:00 |
### 4 - Checklist
* [ ] Scope (1), mapping (2) and timing (3) validated
* [ ] Missing fields created in Tomorro (**Libraries > Dynamic components > Smart fields**), with the same Industry values as the ERP
* [ ] An IT contact or integrator to build the flows, and the brief sent to them
# Technical recipes
Source: https://alpha.developer.tomorro.com/guides/sync-your-counterparties/technical-recipes
Sync your counterparties: prerequisites, the four flows and the API reference for your IT team
Everything runs on the Tomorro REST API: three counterparty endpoints and one picklist endpoint. How you read and write the ERP depends on your ERP.
```mermaid theme={null}
sequenceDiagram
autonumber
actor U as ERP user
participant E as ERP
counterparty base
participant I as Integration
customer middleware
participant T as Tomorro
counterparties
Note over U,T: Prerequisites: "automation" superadmin account, API key, counterparty smart fields, "ID tomorro" column in the ERP
rect rgb(232, 244, 253)
Note over U,T: Flow 0: Initialisation, run once by hand (ERP -> Tomorro -> ERP)
I->>T: GET /v2/smart-fields/{id}/options (picklist option ids)
T-->>I: options
I->>E: List counterparties with an empty ID tomorro
E-->>I: counterparties
loop Each counterparty
I->>T: POST /v2/counterparties
name + fields (smart field id -> value)
T-->>I: 201 data.id
I->>E: Write data.id into ID tomorro
end
end
rect rgb(232, 244, 253)
Note over U,T: Flow 1: Counterparty created in the ERP (ERP -> Tomorro -> ERP)
U->>E: Creates a counterparty
E-->>I: New counterparty (event or polling)
I->>T: POST /v2/counterparties
T-->>I: 201 data.id
I->>E: Write data.id into ID tomorro
end
rect rgb(243, 229, 245)
Note over U,T: Flow 2: Counterparty updated in the ERP (ERP -> Tomorro)
U->>E: Edits a counterparty
E-->>I: Updated counterparty (event or polling)
I->>T: GET /v2/counterparties/{ID tomorro}
T-->>I: current name + fields
opt Something differs
I->>T: PATCH /v2/counterparties/{ID tomorro}
only the changed values
T-->>I: 200
end
end
rect rgb(245, 229, 164)
Note over U,T: Flow 3: Counterparty updated in Tomorro, daily at 8:00 (Tomorro -> ERP)
I->>T: GET /v2/counterparties?sort=-updatedAt
T-->>I: counterparties, latest updates first
Note over I: Keep those updated since the last run
loop Each updated counterparty
I->>E: Find the ERP row by ID tomorro
opt Something differs
I->>E: Update the ERP row with the Tomorro values
end
end
end
```
## 1. Technical prerequisites
**1. 1.1 - Create Tomorro superadmin account**
An existing admin creates a dedicated **superadmin** account from **Settings > Members** (e.g. `automation@customer.com`), not a personal account.
* The API key is bound to a **member**, not to the organization. Every write performed through the API is attributed to that member and inherits its permissions.
* Consequence: the counterparty history shows this account as the author of every creation and update made by the integration. A named account such as "ERP automation" keeps it readable.
* Recommended: no personal mailbox behind it, and exclude it from Tomorro notification settings.
**2. 1.2 - API key**
Generate the API key from that superadmin account. It does not expire. It is sent on every REST call in the `x-api-key` header: [Step 1: Get your API key](/quickstart)
**3. 1.3 - Counterparty smart fields in Tomorro**
Every ERP field other than the name needs a smart field on the counterparty.
* **Libraries > Dynamic components > Smart fields > New**, with **Data related to** = *Counterparty smart field*.
* Pick the type matching the ERP: *Text* for SIRET, address and city, *Single-choice selector* for the industry (options = the ERP values, see 3.5).
* Copy each smart field id from the URL of the field (last segment) next to your mapping ([Non-Technical scoping](/guides/sync-your-counterparties/non-technical-scoping)):
| Mapping | API name |
| - | - |
| Counterparty name | `name` |
| SIRET | `9d6175af-86a5-497a-aa42-facfb2b8dafb` |
| Industry | `b25c969a-2a1c-4903-be86-10269aeb5e84` (option id, see 3.5) |
| Address | `0abf8d86-02ab-4583-aea3-b730ad802c37` |
| City | `a6690cab-baaa-4848-8866-a03d800049e3` |
| ID tomorro | `id` (response) |
**4. 1.4 - ERP: add the ID tomorro column**
Add a **text** column `ID tomorro` to the counterparty table, empty for every counterparty. The integration fills it; users must not edit it. If the ERP supports it, make it read-only for users.
## 2. Flows
### Flow 0 - \[ERP → Tomorro → ERP] Initialisation
**1. Step 0 - Triggered by hand**
Run once, when the integration goes live. Take every counterparty whose `ID tomorro` is empty (and that matches your scope, see [Non-Technical scoping](/guides/sync-your-counterparties/non-technical-scoping)). Re-running it is safe: linked counterparties are skipped.
**2. Step 1 - Get the picklist option ids**
Once per run, for each picklist smart field:
```http theme={null}
GET https://api.tomorro.com/v2/smart-fields/b25c969a-2a1c-4903-be86-10269aeb5e84/options?limit=50
x-api-key:
```
Response `200`:
```json theme={null}
{
"data": [
{ "id": "7c1e2f0a-...", "translations": [ { "language": "gb", "value": "Logistics" } ], "integrationMappings": [] },
{ "id": "0d93b6c4-...", "translations": [ { "language": "gb", "value": "Energy" } ], "integrationMappings": [] }
],
"pagination": { "limit": 50, "next_cursor": null, "has_next": false, "has_previous": false }
}
```
> **Notes**
>
> * Build a `label → id` table from `data[].translations[].value` and `data[].id`. Match labels case-insensitively.
> * Up to 50 options per page: follow `pagination.next_cursor` with `after` if there are more.
> * `integrationMappings` can hold your ERP codes for each option, to translate on codes instead of labels.
**3. Step 2 - Create the counterparty in Tomorro, one call per ERP row**
```http theme={null}
POST https://api.tomorro.com/v2/counterparties
x-api-key:
Content-Type: application/json
{
"name": "Acme Logistics",
"fields": {
"9d6175af-86a5-497a-aa42-facfb2b8dafb": "41234567800012",
"b25c969a-2a1c-4903-be86-10269aeb5e84": "7c1e2f0a-...",
"0abf8d86-02ab-4583-aea3-b730ad802c37": "12 rue de la Paix",
"a6690cab-baaa-4848-8866-a03d800049e3": "Paris"
}
}
```
Response `201`:
```json theme={null}
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Logistics",
"status": "active",
"logoUrl": null,
"fields": {
"b25c969a-2a1c-4903-be86-10269aeb5e84": { "type": "select", "value": "7c1e2f0a-..." },
"a6690cab-baaa-4848-8866-a03d800049e3": { "type": "string", "value": "Paris" }
},
"createdAt": "2026-09-30T08:00:00.000Z",
"updatedAt": "2026-09-30T08:00:00.000Z"
}
}
```
> **Notes**
>
> * `name` is the only required field. `status` defaults to `active`.
> * `fields` keys are **smart field ids** (1.3). Text values as strings, picklists as the **option id** (step 1, 3.5). Leave a field out rather than sending an empty string.
> * The body is strict: an unknown top-level key is refused with a `400`. An unknown smart field id is refused too.
> * Read the new id under `data.id`. In the response, each field comes back as `{ type, value }`, one entry per counterparty smart field of the workspace, filled or not.
> * **Duplicates**: Tomorro does not deduplicate counterparties. Calling this endpoint twice creates two counterparties. This is why Flows 0 and 1 only take counterparties with an empty `ID tomorro`.
**4. Step 3 - Write the Tomorro id back to the ERP**
Out of scope (depends on the ERP). Write `data.id` from step 2 into the `ID tomorro` column, straight away: until it is written, a new run would create the counterparty a second time.
> [!TIP]
>
> **Rate limit**: no need to slow the calls down. If Tomorro answers `429`, retry with backoff: the Make example retries automatically, 3 times, 1 minute apart.
### Flow 1 - \[ERP → Tomorro → ERP] Counterparty created in the ERP
**1. Step 0 - Triggered by the ERP**
Out of scope. A counterparty is created in the ERP: ERP event or webhook if it has one, otherwise poll the counterparties created since the last run. Skip it if its `ID tomorro` is already filled or its name is empty.
**2. Steps 1 to 3 - Same as Flow 0**
Get the option ids, create the counterparty, write `data.id` back to the ERP.
### Flow 2 - \[ERP → Tomorro] Counterparty updated in the ERP
**1. Step 0 - Triggered by the ERP**
Out of scope. A counterparty is updated in the ERP. Only counterparties with an `ID tomorro` go further.
**2. Step 1 - Read the current counterparty and the option ids**
```http theme={null}
GET https://api.tomorro.com/v2/counterparties/{ID tomorro}
x-api-key:
```
Response `200`: same shape as the creation response. Read `data.name` and `data.fields[""].value`. Get the option ids as in Flow 0 step 1.
**3. Step 2 - Compare, then update only what changed**
Compare every mapped ERP value with the Tomorro value (picklists on the option id). If nothing differs, **stop**: the ERP change was on another column, or it is the echo of Flow 1 or Flow 3 (3.3). Otherwise:
```http theme={null}
PATCH https://api.tomorro.com/v2/counterparties/{ID tomorro}
x-api-key:
Content-Type: application/json
{
"fields": {
"b25c969a-2a1c-4903-be86-10269aeb5e84": "0d93b6c4-...",
"a6690cab-baaa-4848-8866-a03d800049e3": null
}
}
```
Response `200`: the updated counterparty.
> **Notes**
>
> * Only the keys you send change. Send `name` only if it changed, and only the smart fields that changed.
> * `null` **clears** a smart field (the value was emptied in the ERP).
> * `404`: the counterparty no longer exists in Tomorro (deleted, or wrong `ID tomorro`). Apply the rule chosen in 3.6.
> * To archive a counterparty deactivated in the ERP, send `{ "status": "archived" }`; `{ "status": "active" }` restores it.
### Flow 3 - \[Tomorro → ERP] Counterparty updated in Tomorro
**1. Step 0 - Scheduled, every day at 8:00**
Read the last `updatedAt` you processed, saved at the end of the previous run. On the first run, look back 24 hours.
**2. Step 1 - List the counterparties updated since the last run**
One call returns the latest updates first:
```http theme={null}
GET https://api.tomorro.com/v2/counterparties?sort=-updatedAt&limit=50
x-api-key:
```
Keep the counterparties whose `updatedAt` is after the saved value, then save the newest `updatedAt` for the next run. Get the option ids once (Flow 0 step 1).
> **Notes**
>
> * `updatedAt` moves on every change of the counterparty, smart fields included.
> * If all 50 counterparties of the page changed since the last run, more are waiting: follow `pagination.next_cursor` with `after`.
> * Counterparties changed by Flow 2 come back here too: the comparison of step 2 finds nothing to write (3.3).
**3. Step 2 - Update the ERP with the values that differ**
Out of scope (depends on the ERP). For each Tomorro counterparty, find the ERP row whose `ID tomorro` equals that counterparty's `id`. No row: the counterparty exists only in Tomorro, ignore it (see [Non-Technical scoping](/guides/sync-your-counterparties/non-technical-scoping)). Otherwise translate picklist option ids back to labels, compare with the ERP and write only the differences. No difference, no write: an unnecessary write would fire Flow 2 again.
## 3. Technical references
Reference material: conventions, the counterparty object and error handling. Read it once, then come back to it while implementing the flows above.
### 3.1 - API conventions
* **REST only.** The whole integration runs on the public REST API.
* **Base URL**: `https://api.tomorro.com/v2`
* **Authentication**: `x-api-key: ` on every call. A missing or unknown key returns 401.
* **Versioning**: optional `tomorro-version` header, defaults to the latest version.
* **Rate limit**: a `429` means too many calls in a short time. Retry with backoff.
* **Sandbox**: available on request. It is an empty sandbox, not a copy of your production workspace.
* **Content type**: `application/json`
* **Response envelope**: every **successful** JSON response is wrapped in a `data` object (errors come as `{ error, meta }`, see 3.4). Read `data.id`, `data.fields` and so on.
* **Full reference**: [Counterparties - Tomorro API](/api-reference/introduction)
### 3.2 - Counterparty endpoints and fields
| Endpoint | Use in this integration |
| - | - |
| `POST /v2/counterparties` | Flows 0, 1: create. Returns `201` with the counterparty. |
| `GET /v2/counterparties/{id}` | Flows 2, 3: read the current values. |
| `PATCH /v2/counterparties/{id}` | Flow 2: update. Only the keys sent change. Returns `200`. |
| `GET /v2/smart-fields/{id}/options` | All flows: picklist option ids and labels. `limit` 1 to 50, cursor pagination, `term` filters on the label. |
| `GET /v2/counterparties` | Flow 3: `sort=-updatedAt` returns the latest updates first. `limit` 1 to 50, cursor pagination, `name` (partial match), `status`. Also useful to spot duplicates before Flow 0. |
| Field | Values | Note |
| - | - | - |
| `name` | string, required on creation | The counterparty name shown everywhere in Tomorro. |
| `status` | `active` , `archived` | Default `active`. Archive rather than delete. |
| `fields` (request) | `{ "": value }` | Text as a string, picklist as the option id, multi-choice as an array of option ids, `null` to clear. |
| `fields` (response) | `{ "": { type, value } }` | One entry per counterparty smart field of the workspace. `value` is `null` when empty, an option id for a picklist. |
| `fields[].type` | `string` , `number` , `date` , `select` , `multiselect` , `duration` , `rich_text` , `file` | Type of the smart field. |
| `updatedAt` | ISO 8601 | Moves on every change of the counterparty, smart fields included. Flow 3 relies on it. |
### 3.3 - Loops and conflicts
Flows 1 and 3 write into the ERP (so Flow 2 sees an updated counterparty), Flow 2 writes into Tomorro (so Flow 3 reads a different counterparty). Without care, a change bounces forever.
* **Compare before writing, in both directions.** Flow 2 only sends a `PATCH` when an ERP value differs from Tomorro; Flow 3 only writes the ERP when a Tomorro value differs. The echo of a write finds identical values and stops.
* **Compare normalised values**: trim spaces, treat empty and `null` the same, compare picklists on the option id.
* **Same-day conflict**: if a field is changed in both systems between two runs of Flow 3, the ERP value wins, since Flow 2 runs first.
### 3.4 - Error handling
Every endpoint follows the standard error format of the REST API.
**Error envelope**
```json theme={null}
{
"error": {
"statusCode": "400",
"errorId": "optional, present on some domain errors",
"message": "Human readable message",
"details": [
{ "field": "name", "message": "..." }
]
},
"meta": {
"timestamp": "2026-09-30T10:09:22.634Z",
"requestId": "req_Ab12Cd34"
}
}
```
* `error.statusCode` is a **string**, not a number.
* `meta.requestId` is the value to quote to Tomorro support when reporting a failing call. Log it.
| Code | Meaning | Retry? |
| - | - | - |
| `400` | Invalid body: missing `name`, unknown key, wrong value type. | No, fix the payload |
| `401` | Missing or unknown `x-api-key`. | No |
| `403` | The API key's account is not allowed to manage counterparties. | No, check 1.1 |
| `404` | Unknown counterparty id, or unknown smart field id in `fields`. | No, see 3.6 |
| `429` | Rate limit exceeded. | Yes, with backoff |
| `500` / timeout | Internal error. | Yes, with backoff. On a creation, check first that the counterparty was not created (`GET /v2/counterparties?name=...`) to avoid a duplicate. |
### 3.5 - Picklists
* A picklist value is stored as the **option id**, not its label. Send `"Logistics"` and the API accepts it, but Tomorro shows an empty field.
* Get the ids with `GET /v2/smart-fields/b25c969a-2a1c-4903-be86-10269aeb5e84/options` and translate label ↔ id in the integration, in both directions.
* Align the labels on both sides first (here, `Software & Saas` in Tomorro renamed `Software & SaaS`). A label missing from Tomorro is left empty, never invented.
### 3.6 - Edge cases
* **Link**: an `ID tomorro` column in the ERP. Never match on name or SIRET.
* **Existing counterparties** before Flow 0: clean them up, or link them by filling `ID tomorro` by hand (Flow 0 skips linked rows).
* **Counterparty deactivated in the ERP**: archive the counterparty (`status: archived`) or ignore.
* **Counterparty deleted in Tomorro**: Flows 2 and 3 get a `404`. Alert, or clear `ID tomorro`.
* **Loops**: every update flow compares before writing, so the echo of a write stops by itself (3.3).
* **Triggers**: prefer an ERP webhook or event, so Flows 1 and 2 run as soon as a counterparty changes. Without one, poll the counterparties changed since the last run, e.g. every 15 minutes.
# Tomorro Public API
Source: https://alpha.developer.tomorro.com/index
Integrate Tomorro's contract management capabilities into your applications
The Tomorro Public API enables you to programmatically manage your organization's members. Build powerful integrations that automate your member management and onboarding processes.
> **[Quickstart](/quickstart)**
>
> Get up and running with your first API call in minutes.
> **[API Reference](/api-reference/introduction)**
>
> Explore the complete API documentation with interactive examples.
> **[API Versioning](/versioning)**
>
> Learn how we version the API and how to pin or upgrade versions.
> **[Guides](/guides/overview)**
>
> Step-by-step guides for the integrations teams build with the API.
## Need help?
> **[Contact Support](mailto:support@tomorro.com)**
>
> Reach out to our team for technical assistance.
> **[Visit Tomorro](https://www.tomorro.com)**
>
> Learn more about Tomorro's contract management platform.
# Connect your Tomorro account
Source: https://alpha.developer.tomorro.com/mcp/connection
Sign in to Tomorro from your AI assistant, once per member
Once your administrator has [installed Tomorro MCP](/mcp/install), each member connects their own
Tomorro account from the assistant.
> [!NOTE]
>
> **Every member does this for themselves, only once.** The connection is personal: the assistant
> acts with your Tomorro account, so it only sees and changes what you are already allowed to see
> and change in Tomorro.
## Claude
[Tomorro x Claude — Connect your Tomorro account](https://demo.arcade.software/AryK2jdBPClY8CgtMlVF?embed\&embed_mobile=tab\&embed_desktop=inline\&show_copy_link=true)
Can't see the demo? [Open it in a new tab](https://app.arcade.software/share/AryK2jdBPClY8CgtMlVF).
## ChatGPT
[Tomorro x ChatGPT — Connect your Tomorro account](https://demo.arcade.software/cAficDb1kmZZnSDaidRD?embed\&embed_mobile=tab\&embed_desktop=inline\&show_copy_link=true)
Can't see the demo? [Open it in a new tab](https://app.arcade.software/share/cAficDb1kmZZnSDaidRD).
## Other assistants
Open the Tomorro connector your administrator added, click **Connect**, and sign in with your
Tomorro account. If you belong to several Tomorro workspaces, choose the one the assistant should
work in.
# Install Tomorro MCP
Source: https://alpha.developer.tomorro.com/mcp/install
Add the Tomorro MCP server to your AI assistant, once for the whole workspace
The Tomorro MCP server is a hosted service: there is nothing to install on your machine. You add it
once in your AI assistant, then each member connects their own Tomorro account.
> [!NOTE]
>
> **Only the administrator of your AI assistant's workspace needs to do this**, and only once.
> Once Tomorro is added, it is available to every member of the workspace, who then
> [connect their own account](/mcp/connection).
## What you'll need
| Setting | Value |
| - | - |
| **MCP server URL** | `https://api.tomorro.com/v2/mcp` |
| **Authentication** | OAuth — each member signs in with their Tomorro account when connecting |
## Claude
[Tomorro x Claude — Install Tomorro MCP](https://demo.arcade.software/i80NZEqenjstol4awvSI?embed\&embed_mobile=tab\&embed_desktop=inline\&show_copy_link=true)
Can't see the demo? [Open it in a new tab](https://app.arcade.software/share/i80NZEqenjstol4awvSI).
### Allow contract file downloads
To let Claude download contract documents (for example the Word version of a contract), add the
Tomorro file storage domains to your Claude organization's domain allowlist. In **Settings >
Capabilities > Domain allowlist**, add both domains under **Additional allowed domains**:
```
tomorro-prod-temporary-storage.s3.eu-west-3.amazonaws.com
```
```
s3.eu-west-3.amazonaws.com
```

## ChatGPT
[Tomorro x ChatGPT — Install Tomorro MCP](https://demo.arcade.software/BdU3Jtpmah4CpTFfzgL2?embed\&embed_mobile=tab\&embed_desktop=inline\&show_copy_link=true)
Can't see the demo? [Open it in a new tab](https://app.arcade.software/share/BdU3Jtpmah4CpTFfzgL2).
**Official guide:** [Developer mode, apps and full MCP connectors in ChatGPT](https://help.openai.com/en/articles/12584461-developer-mode-apps-and-full-mcp-connectors-in-chatgpt-beta)
## Other assistants
Each assistant documents how to add a remote MCP server with OAuth. Follow their guide with the
Tomorro server URL above.
| Assistant | Official guide |
| - | - |
| **Dust** | [Adding an MCP Server](https://docs.dust.tt/docs/remote-mcp-server) |
| **Notion** | [Connect MCP servers to your Notion Agent](https://www.notion.com/help/connect-mcp-servers-to-your-notion-agent) |
| **Gemini** | [Set up your custom MCP server — Gemini Enterprise](https://docs.cloud.google.com/gemini/enterprise/docs/connectors/custom-mcp-server/set-up-custom-mcp-server) |
| **Copilot** | [Connect your agent to an existing MCP server — Microsoft Copilot Studio](https://learn.microsoft.com/en-us/microsoft-copilot-studio/mcp-add-existing-server-to-agent) |
Once Tomorro is installed, share the [connection guide](/mcp/connection) with your team.
# Overview
Source: https://alpha.developer.tomorro.com/mcp/overview
Bring your contracts into the AI assistant you already use — to read and to act
Connect Tomorro's MCP server to bring your contracts into the AI assistant you already use. Your
assistant can now **read** your Tomorro data and **act** on it: create a contract, update a
counterparty, upload a signed version, send a contract for signature, and more.
Each person connects with their own Tomorro credentials, so the assistant only sees and changes
what that user is already allowed to see and change in Tomorro.
## What is Tomorro's MCP capable of?
The Tomorro MCP server is a secure bridge to our public REST API. Once connected, your AI
assistant can explore and manage your Tomorro workspace through natural language, bringing
structured contract data directly into your conversations. Responses are trimmed to what the
assistant needs, so answers come back faster and long conversations stay focused.
[Tomorro MCP presentation](https://www.loom.com/embed/debf3ebf550845df809df606ae72fa9a)
### Tools in detail

> **All 39 tools in detail**
>
> | Area | Tool | Access | Description |
> | - | - | - | - |
> | Contracts | `tomorro_list_contracts` | Read | Search and list contracts |
> | Contracts | `tomorro_get_contract` | Read | Retrieve a contract's metadata, fields, signatories and members |
> | Contracts | `tomorro_get_consolidated_contract` | Read | Retrieve a contract with field values updated by its amendments |
> | Contracts | `tomorro_get_contract_clauses` | Read | List the clauses attached to a contract |
> | Contracts | `tomorro_get_contract_current_version` | Read | Download the current working document (PDF or Word) |
> | Contracts | `tomorro_get_contract_signed_files` | Read | Download the signed PDFs of a signed contract |
> | Contracts | `tomorro_create_contract` | Write | Create a contract from a contract type and a template |
> | Contracts | `tomorro_update_contract` | Write | Update a contract's fields, counterparty, signatories or members |
> | Contracts | `tomorro_create_contract_version` | Write | Upload a new version of a contract's document |
> | Contracts | `tomorro_add_contract_attachment` | Write | Attach a supporting file (annex, certificate…) to a contract |
> | Contracts | `tomorro_request_contract_signatures` | Write | Send a contract for signature |
> | Contracts | `tomorro_upload_contract_signed_version` | Write | Mark a contract as signed from PDFs signed outside Tomorro |
> | Files | `tomorro_create_file_upload_url` | Write | Get a secure link to upload a file before attaching it to a contract |
> | Contract types | `tomorro_list_contract_types` | Read | List the contract types available |
> | Contract types | `tomorro_get_contract_type_templates` | Read | List the templates of a contract type, by language |
> | Contract types | `tomorro_get_contract_type_creation_form` | Read | Return the smart fields required to create a contract |
> | Contract types | `tomorro_get_contract_type_template_content` | Read | Read the wording of a template |
> | Smart fields | `tomorro_list_smart_fields` | Read | List smart fields |
> | Smart fields | `tomorro_get_smart_field_options` | Read | List the options of a select smart field |
> | Custom objects | `tomorro_list_custom_objects` | Read | List the custom objects |
> | Custom objects | `tomorro_get_custom_object` | Read | Retrieve a custom object with its smart fields |
> | Custom objects | `tomorro_list_contract_records` | Read | List the records a contract holds for a custom object |
> | Custom objects | `tomorro_upsert_contract_records` | Write | Create or update the records a contract holds for a custom object |
> | Clauses | `tomorro_list_clauses` | Read | List all clauses of the library |
> | Clauses | `tomorro_get_clause` | Read | Retrieve a clause with its content |
> | Clauses | `tomorro_create_clause` | Write | Add a clause to the library |
> | Counterparties | `tomorro_list_counterparties` | Read | Search and list counterparties |
> | Counterparties | `tomorro_get_counterparty` | Read | Retrieve a counterparty's profile |
> | Counterparties | `tomorro_create_counterparty` | Write | Create a counterparty |
> | Counterparties | `tomorro_update_counterparty` | Write | Update a counterparty's name, status or fields |
> | Members | `tomorro_list_members` | Read | List members, filtered by role, status or email |
> | Members | `tomorro_get_member` | Read | Retrieve a member with their role and groups |
> | Members | `tomorro_get_member_out_of_office` | Read | Check whether a member is out of office |
> | Members | `tomorro_create_member` | Write | Add a member and optionally send an invitation |
> | Members | `tomorro_update_member` | Write | Update a member's role, status, groups or folders |
> | Members | `tomorro_set_member_out_of_office` | Write | Set your own out-of-office period |
> | Members | `tomorro_disable_member_out_of_office` | Write | End your own out-of-office period |
> | Organization | `tomorro_get_organization` | Read | Retrieve the organization you are connected to |
> | Audit logs | `tomorro_list_audit_logs` | Read | Review who did what in your workspace (admins only) |
### Examples & Usecases
[Tomorro MCP use cases](https://www.loom.com/embed/36edf98ea5d04f4b8abf321dcbffe6a8)
* "Which contracts are expiring in the next 90 days?"
* "Show me all NDAs signed with Acme Corp this year."
* "Create an NDA with Acme Corp in English, with Jane Doe as internal signatory."
* "What's the status of the master agreement with supplier X?"
* "Send the service agreement with supplier X for signature."
* "Which clauses are attached to contract `ctr_xxx`?"
* "Add our new confidentiality clause to the clause library."
* "Here are the PDFs signed in DocuSign — mark the partnership agreement as signed."
* "Archive the counterparty Old Supplier Ltd."
* "Invite [john@acme.com](mailto:john@acme.com) as a member with the USER role."
* "I'm on leave from August 1st to August 15th, set my out-of-office."
* "Which smart fields are required to create a 'Service Agreement' contract?"
# Quickstart
Source: https://alpha.developer.tomorro.com/quickstart
Make your first API call in minutes
This guide will help you make your first request to the Tomorro API.
## Step 1: Get your API key
To authenticate with the Tomorro API, you need an API key.
**1. Log in to Tomorro**
Go to [app.tomorro.com](https://app.tomorro.com) and sign in to your account.
**2. Navigate to API Settings**
Go to **Settings** → **API** in your organization dashboard.
**3. Generate an API Key**
Click **Create API Key**, give it a name, and copy the generated key.
\[!WARNING] Store your API key securely. It won't be shown again after you leave the page.
## Step 2: Make your first request
Let's list the members in your organization. Replace `your-api-key` with your actual API key.
```bash cURL theme={null}
curl -X GET "https://api.tomorro.com/v2/members" \
-H "x-api-key: your-api-key" \
-H "Content-Type: application/json"
```
```javascript JavaScript theme={null}
const response = await fetch('https://api.tomorro.com/v2/members', {
method: 'GET',
headers: {
'x-api-key': 'your-api-key',
'Content-Type': 'application/json',
},
});
const data = await response.json();
console.log(data);
```
```python Python theme={null}
import requests
response = requests.get(
'https://api.tomorro.com/v2/members',
headers={
'x-api-key': 'your-api-key',
'Content-Type': 'application/json'
}
)
print(response.json())
```
You should receive a response like this:
```json theme={null}
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"role": "ADMIN",
"status": "active",
"user": {
"email": "john.doe@example.com",
"firstname": "John",
"lastname": "Doe"
},
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
],
"pagination": {
"hasNext": false,
"hasPrevious": false,
"nextCursor": null,
"previousCursor": null
}
}
```
## Step 3: Create a member
Now let's create a new member in your organization:
```bash cURL theme={null}
curl -X POST "https://api.tomorro.com/v2/members" \
-H "x-api-key: your-api-key" \
-H "Content-Type: application/json" \
-d '{
"username": "jane.smith@example.com",
"role": "USER",
"sendInvitation": true
}'
```
```javascript JavaScript theme={null}
const response = await fetch('https://api.tomorro.com/v2/members', {
method: 'POST',
headers: {
'x-api-key': 'your-api-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: 'jane.smith@example.com',
role: 'USER',
sendInvitation: true,
}),
});
const data = await response.json();
console.log(data);
```
```python Python theme={null}
import requests
response = requests.post(
'https://api.tomorro.com/v2/members',
headers={
'x-api-key': 'your-api-key',
'Content-Type': 'application/json'
},
json={
'username': 'jane.smith@example.com',
'role': 'USER',
'sendInvitation': True
}
)
print(response.json())
```
# API Versioning
Source: https://alpha.developer.tomorro.com/versioning
How Tomorro versions its API and how to use versioned requests
The Tomorro Public API uses **date-based versioning** with a codename: `YYYY-MM-DD.codename`. You send the desired version in a header; the API validates it, runs the matching behavior, and returns the version and any deprecation info in the response.
## Version format
Versions follow this pattern:
* **Date**: `YYYY-MM-DD` (e.g. `2025-01-29`)
* **Codename**: lowercase label (e.g. `alpha`, `init`, ...)
Example: `2025-01-29.alpha`.
## How to specify a version
Send the `tomorro-version` header on every request:
```bash theme={null}
curl -X GET "https://api.tomorro.com/v2/members" \
-H "x-api-key: your-api-key" \
-H "tomorro-version: 2025-01-29.alpha"
```
If you omit `tomorro-version`, the API uses the **latest** version. We recommend setting the header explicitly so upgrades are intentional.
## Version lifecycle
Each version has a status:
| Status | Meaning |
| - | - |
| **supported** | Actively supported; use this for new integrations. |
| **deprecated** | Still works but scheduled for removal. Migrate to a supported version. |
| **sunset** | No longer available; requests with this version return `410 Gone`. |
When you call the API with a **deprecated** version, we still process the request but add extra response headers so you can detect and plan migration:
* **`x-api-deprecation-warning`**: Sent on every response when using a deprecated version. Contains a message (e.g. *"This version is deprecated. Please upgrade to 2025-01-29.alpha."*).
* **`sunset`**: The date when the version will be removed. After that date, the same version will return `410 Gone` instead of a successful response.
The following timeline illustrates how versions move from release through current, deprecation, and sunset, with new versions introduced at breaking changes:
```mermaid theme={null}
gantt
title Version lifecycle timeline
dateFormat YYYY-MM-DD
axisFormat %Y-%m
section Version:
jimmy-2024-01-01
Release :milestone, active, rel1, 2024-01-01, 0d
Current :active, cur1, 2024-01-01, 2024-06-01
Deprecation :milestone, crit, dep1, 2024-06-01, 0d
Deprecated :crit, depPhase1, 2024-06-01, 2024-12-01
Sunset :milestone, done, sun1, 2024-12-01, 0d
section Version:
renewal-2024-06-01
Breaking Change :milestone, crit, brk1, 2024-06-01, 0d
Release :milestone, active, rel2, 2024-06-01, 0d
Current :active, cur2a, 2024-06-01, 2024-10-01
Supported :done, cur2b, 2024-10-01, 2025-06-01
section Version:
renewal-2024-10-01
Release :milestone, active, rel3, 2024-10-01, 0d
Current :active, cur3, 2024-10-01, 2025-06-01
```
## Strategy in short
1. **Header-based**: Version is chosen via `tomorro-version`, not the URL path.
2. **Default to latest**: Omitted header implies the current latest version.
3. **Explicit versions**: New versions are introduced when we make breaking changes; existing versions stay available until deprecated then sunset.
4. **Stable contract per version**: Once a version is released, we do not change its behavior; use a newer version for new features or breaking changes.
## Current versions
Check the **API Reference** (version selector in the docs) for the list of available versions and their OpenAPI specs. The docs and reference are split by version so you see the right request/response shapes for each.
## Best practices
> **Pin your version**
>
> Set `tomorro-version` explicitly in your client so behavior stays predictable until you choose to upgrade.
> **Handle deprecation**
>
> Read `x-api-deprecation-warning` and `sunset` and plan migration before a version is sunset.
# Overview
Source: https://alpha.developer.tomorro.com/webhooks/overview
Receive real-time HTTP notifications when events happen in Tomorro
Webhooks let Tomorro notify your systems in real time when something happens in your
organization — instead of polling the API. When a subscribed event occurs, Tomorro sends an
HTTP `POST` request to a URL you control, with the event details in the body.
## Available events
A webhook can be triggered by any of the following actions:
| Event type | Triggered when |
| - | - |
| `contractCreated` | A contract is created |
| `contractDeleted` | A contract is deleted |
| `contractSigned` | A contract is fully signed |
| `contractStatusUpdated` | A contract's status changes |
| `reminderEventCreated` | A reminder is triggered |
> [!NOTE]
>
> **Webhooks are personal.** A webhook only fires for events that concern the member who
> created it.
>
> * The `contractCreated` webhook only triggers if you are a participant of the contract.
> * The `reminderEventCreated` webhook only triggers if the reminder is intended for you.
## Create a webhook
**1. Open the Webhooks settings**
Go to **My account → Integrations → Webhooks**, or open it directly:
> **[Open Webhooks settings](https://app.tomorro.com/settings/integrations?integration=webhook)**
>
> app.tomorro.com → Settings → Integrations → Webhooks
**2. Add a webhook**
Click **Add a webhook**.
**3. Configure it**
1. **Name** your webhook.
2. Paste the **URL** that will receive the events.
3. Select one or more **triggers** (the events that activate it).
4. Click **Create webhook**.
**4. Save your signing secret**
A secret is generated for the webhook. Tomorro uses it to sign every event so you can
verify authenticity — keep it safe and use it to check the
[`Leeway-Signature`](/webhooks/technical-documentation#signature-header) header.
## Event payloads
Every webhook is delivered as an HTTP `POST` with a JSON body. All events share the same
top-level envelope; the event-specific content lives under `data`.
### Envelope
| Field | Type | Description |
| - | - | - |
| `eventId` | `string` | Unique identifier of this event delivery (UUID). |
| `webhookId` | `string` | Identifier of the webhook that produced the event (UUID). |
| `createdAt` | `string` | ISO 8601 timestamp of when the event was created. |
| `eventType` | `string` | One of the [available event types](#available-events). |
| `data` | `object` | The event-specific payload (see examples below). |
> [!NOTE]
>
> The `data` shape depends on `eventType`. Contract events wrap the contract under
> `data.contract`. Always branch on `eventType` before reading `data`.
### Examples
> **contractCreated**
>
> ```json theme={null}
> {
> "eventId": "b21213e3-8a9a-4e04-9bfc-c4e53f123xxx",
> "webhookId": "2a76094c-1f2e-48c8-a47f-1add41234xxx",
> "createdAt": "2025-03-25T14:55:16.280Z",
> "eventType": "contractCreated",
> "data": {
> "contract": {
> "id": "6954ad41-19f3-446f-b0db-fcc661234xxx",
> "name": "TestWebhook",
> "organizationId": "115e926b-9c61-4172-8609-02212344exxx",
> "author": {
> "id": "974d2a1c-eb63-4237-877e-37d12341axxx",
> "user": {
> "id": "fd61234d-a4e1-4103-8d31-59e351d2bxxx",
> "username": "user@example.com",
> "firstname": "John",
> "lastname": "Doe"
> }
> },
> "referent": {
> "id": "974d2a1c-eb63-4237-877e-37123451axxx",
> "user": {
> "id": "fd61234d-a4e1-4103-8d31-59e351d2bxxx",
> "username": "user@example.com",
> "firstname": "John",
> "lastname": "Doe"
> }
> },
> "referentId": "974d2a1c-eb63-4237-877e-37123451axxx",
> "supervisorId": null,
> "externalCompany": {
> "id": "01611234-7cdf-4785-966e-ee06b74ccxxx",
> "name": "My external company"
> },
> "priorNoticeDuration": "undefined",
> "priorNoticeValue": null,
> "renewal": "no",
> "signatureDate": null,
> "status": "draft",
> "createdAt": "2025-03-25T14:55:14.690Z",
> "updatedAt": "2025-03-25T14:55:14.000Z",
> "documentId": null,
> "typeId": null,
> "templateId": null,
> "contractAttributes": [
> { "attributeDefinition": { "name": "durationType" }, "value": "PERMANENT" },
> { "attributeDefinition": { "name": "endAt" }, "value": null },
> { "attributeDefinition": { "name": "externalPartyName" }, "value": "My external company" },
> { "attributeDefinition": { "name": "initialDuration" }, "value": null },
> { "attributeDefinition": { "name": "internalPartyName" }, "value": "John Doe" },
> { "attributeDefinition": { "name": "language" }, "value": "fr" },
> { "attributeDefinition": { "name": "nextRenewalDate" }, "value": null },
> { "attributeDefinition": { "name": "priorNotice" }, "value": null },
> { "attributeDefinition": { "name": "renewalDuration" }, "value": null },
> { "attributeDefinition": { "name": "renewalType" }, "value": "no" },
> { "attributeDefinition": { "name": "signatureDate" }, "value": null },
> { "attributeDefinition": { "name": "startAt" }, "value": null }
> ],
> "attributes": {
> "durationType": { "value": "PERMANENT", "name": "durationType", "attributeDefinitionId": "durationType" },
> "endAt": { "value": null, "name": "endAt", "attributeDefinitionId": "endAt" },
> "externalPartyName": { "value": "My external company", "name": "externalPartyName", "attributeDefinitionId": "externalPartyName" },
> "initialDuration": { "value": null, "name": "initialDuration", "attributeDefinitionId": "initialDuration" },
> "internalPartyName": { "value": "John Doe", "name": "internalPartyName", "attributeDefinitionId": "internalPartyName" },
> "language": { "value": "fr", "name": "language", "attributeDefinitionId": "language" },
> "nextRenewalDate": { "value": null, "name": "nextRenewalDate", "attributeDefinitionId": "nextRenewalDate" },
> "priorNotice": { "value": null, "name": "priorNotice", "attributeDefinitionId": "priorNotice" },
> "renewalDuration": { "value": null, "name": "renewalDuration", "attributeDefinitionId": "renewalDuration" },
> "renewalType": { "value": "no", "name": "renewalType", "attributeDefinitionId": "renewalType" },
> "signatureDate": { "value": null, "name": "signatureDate", "attributeDefinitionId": "signatureDate" },
> "startAt": { "value": null, "name": "startAt", "attributeDefinitionId": "startAt" }
> }
> }
> }
> }
> ```
> **contractStatusUpdated**
>
> Status-update events include the contract's lifecycle fields (`status`, `dateStatus`,
> `realEndDate`, …) and the resolved `attributes`, where each attribute carries a human-readable
> `valueLabel` in addition to its raw `value`.
>
> ```json theme={null}
> {
> "eventId": "dcdfce0c-5914-42b2-bcda-ce53b0afbed7",
> "webhookId": "a6493b01-0a87-4fd6-a851-8def5a76c9a1",
> "createdAt": "2026-06-10T13:08:43.801Z",
> "eventType": "contractStatusUpdated",
> "data": {
> "contract": {
> "id": "b9ce25f0-9b85-4c31-acf4-e6311e95xxxc",
> "name": "NDA - Adobe",
> "organizationId": "392ef54d-dc59-41f6-8ef9-37c6a1b7xxx3",
> "author": {
> "id": "53a9760f-ad03-4f18-b7e0-a52fe303xxx7",
> "user": {
> "id": "b30bb4d4-007c-4a93-b2e7-0be8b0bxxx9f",
> "username": "user@example.com",
> "firstname": "John",
> "lastname": "Doe"
> }
> },
> "referent": {
> "id": "53a9760f-ad03-4f18-b7e0-a52fe303xxx7",
> "user": {
> "id": "b30bb4d4-007c-4a93-b2e7-0be8b0xxx59f",
> "username": "user@example.com",
> "firstname": "John",
> "lastname": "Doe"
> }
> },
> "referentId": "53a9760f-ad03-4f18-b7e0-a52fe303xxx7",
> "supervisor": null,
> "supervisorId": null,
> "externalCompany": {
> "id": "e7d3d7d6-0f0b-4bdf-9233-7b7188aaxxx4",
> "name": "Adobe"
> },
> "durationType": "FIXED_TERM",
> "startAt": "2026-01-01T00:00:00.000Z",
> "endAt": "2026-01-31T00:00:00.000Z",
> "terminatedAt": null,
> "priorNoticeDuration": "undefined",
> "priorNoticeValue": null,
> "renewal": "no",
> "renewalType": "no",
> "renewalDuration": null,
> "renewalDurationUnit": null,
> "isRenewable": false,
> "signatureDate": null,
> "status": "negotiating",
> "dateStatus": "negotiating",
> "nextRenewedDate": null,
> "realEndDate": "2026-01-31T00:00:00.000Z",
> "createdAt": "2026-06-10T13:07:40.997Z",
> "updatedAt": "2026-06-10T13:08:43.000Z",
> "typeId": "7591d403-53df-41d4-8d5d-ec56aad4e375",
> "templateId": "74829184-734a-4f08-b0d5-9e36f67db126",
> "contractAttributes": [
> { "attributeDefinition": { "name": "Adresse du cocontractant" }, "value": "a" },
> { "attributeDefinition": { "name": "Département" }, "value": "a05e7e8d-a149-4c38-85e2-12cfc79674de" },
> { "attributeDefinition": { "name": "Loi applicable" }, "value": "915a2d5f-b6ba-4b2d-a01e-c63932a4c783" },
> { "attributeDefinition": { "name": "Forme juridique du cocontractant" }, "value": "6917969e-3e7f-4cc8-a027-4f54cfce32ee" },
> { "attributeDefinition": { "name": "Richtext" }, "value": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Sample text\"}]}]" },
> { "attributeDefinition": { "name": "Équipe" }, "value": "396c1bd9-7740-411f-bab4-854b995c2734" },
> { "attributeDefinition": { "name": "Montant" }, "value": "12000" },
> { "attributeDefinition": { "name": "Type de durée" }, "value": "FIXED_TERM" },
> { "attributeDefinition": { "name": "Date de fin" }, "value": "2026-01-31" },
> { "attributeDefinition": { "name": "Counterparty name" }, "value": "Adobe" },
> { "attributeDefinition": { "name": "Durée " }, "value": "1:month" },
> { "attributeDefinition": { "name": "Dénomination sociale de votre organisation" }, "value": "Acme Corp" },
> { "attributeDefinition": { "name": "Language of the document" }, "value": "fr" },
> { "attributeDefinition": { "name": "général Tomorro - Prochaine date de reconduction" }, "value": null },
> { "attributeDefinition": { "name": "Préavis" }, "value": null },
> { "attributeDefinition": { "name": "ID du projet" }, "value": "260600003" },
> { "attributeDefinition": { "name": "général Tomorro - Durée de reconduction" }, "value": null },
> { "attributeDefinition": { "name": "Type de reconduction" }, "value": "no" },
> { "attributeDefinition": { "name": "Date de signature du contrat-cadre" }, "value": null },
> { "attributeDefinition": { "name": "Start date" }, "value": "2026-01-01" }
> ],
> "attributes": {
> "0144e4a2-75ed-4893-954f-84686eb94ad6": { "value": "a", "name": "Adresse du cocontractant", "attributeDefinitionId": "0144e4a2-75ed-4893-954f-84686eb94ad6", "valueLabel": "a" },
> "12caf9df-d15d-4267-88b6-29b0ca9e4659": { "value": "a05e7e8d-a149-4c38-85e2-12cfc79674de", "name": "Département", "attributeDefinitionId": "12caf9df-d15d-4267-88b6-29b0ca9e4659", "valueLabel": "Comptabilité / Finances" },
> "14862943-7b0f-49ed-aa0a-33d1084fe1b6": { "value": "915a2d5f-b6ba-4b2d-a01e-c63932a4c783", "name": "Loi applicable", "attributeDefinitionId": "14862943-7b0f-49ed-aa0a-33d1084fe1b6", "valueLabel": "FR" },
> "32dc2fd1-6048-43aa-bbde-bb9e2b19f740": { "value": "6917969e-3e7f-4cc8-a027-4f54cfce32ee", "name": "Forme juridique du cocontractant", "attributeDefinitionId": "32dc2fd1-6048-43aa-bbde-bb9e2b19f740", "valueLabel": "EI" },
> "6dd82116-95a4-40a7-8100-4e9776d01d7f": { "value": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Sample text\"}]}]", "name": "Richtext", "attributeDefinitionId": "6dd82116-95a4-40a7-8100-4e9776d01d7f", "valueLabel": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Sample text\"}]}]" },
> "96864b82-de02-43ad-8d68-1f60273d8aa5": { "value": "396c1bd9-7740-411f-bab4-854b995c2734", "name": "Équipe", "attributeDefinitionId": "96864b82-de02-43ad-8d68-1f60273d8aa5", "valueLabel": "Tech" },
> "d6cf7bb4-c897-498c-85ec-cf02251e6f9d": { "value": "12000", "name": "Montant", "attributeDefinitionId": "d6cf7bb4-c897-498c-85ec-cf02251e6f9d", "valueLabel": "12000" },
> "durationType": { "value": "FIXED_TERM", "name": "Type de durée", "attributeDefinitionId": "durationType", "valueLabel": "FIXED_TERM" },
> "endAt": { "value": "2026-01-31", "name": "Date de fin", "attributeDefinitionId": "endAt", "valueLabel": "2026-01-31" },
> "externalPartyName": { "value": "Adobe", "name": "Counterparty name", "attributeDefinitionId": "externalPartyName", "valueLabel": "Adobe" },
> "initialDuration": { "value": "1:month", "name": "Durée ", "attributeDefinitionId": "initialDuration", "valueLabel": "1:month" },
> "internalPartyName": { "value": "Acme Corp", "name": "Dénomination sociale de votre organisation", "attributeDefinitionId": "internalPartyName", "valueLabel": "Acme Corp" },
> "language": { "value": "fr", "name": "Language of the document", "attributeDefinitionId": "language", "valueLabel": "fr" },
> "nextRenewalDate": { "value": null, "name": "général Tomorro - Prochaine date de reconduction", "attributeDefinitionId": "nextRenewalDate", "valueLabel": null },
> "priorNotice": { "value": null, "name": "Préavis", "attributeDefinitionId": "priorNotice", "valueLabel": null },
> "projectIdentifier": { "value": "260600003", "name": "ID du projet", "attributeDefinitionId": "projectIdentifier", "valueLabel": "260600003" },
> "renewalDuration": { "value": null, "name": "général Tomorro - Durée de reconduction", "attributeDefinitionId": "renewalDuration", "valueLabel": null },
> "renewalType": { "value": "no", "name": "Type de reconduction", "attributeDefinitionId": "renewalType", "valueLabel": "no" },
> "signatureDate": { "value": null, "name": "Date de signature du contrat-cadre", "attributeDefinitionId": "signatureDate", "valueLabel": null },
> "startAt": { "value": "2026-01-01", "name": "Start date", "attributeDefinitionId": "startAt", "valueLabel": "2026-01-01" }
> }
> }
> }
> }
> ```
# Technical documentation
Source: https://alpha.developer.tomorro.com/webhooks/technical-documentation
Signature verification, delivery, retries, and security policy
## Security policy
Anyone who knows your endpoint URL could send it requests. To be sure an event genuinely comes
from Tomorro, **verify the signature on every request** before acting on it.
### Signing secret
Each webhook has its own secret, generated when you create it and shared only between Tomorro
and you. Tomorro uses it to sign every event; you use it to verify them. Treat it like a
password — never expose it client-side or commit it to source control.
### Signature header
Every event is sent with a signature header. Tomorro signs the event by computing an
HMAC-SHA256 over the timestamp and the raw request body, using your webhook secret:
```
sha256 = HMAC_SHA256(secret, timestamp + "." + rawBody)
```
The signature is delivered in the `Leeway-Signature` header, in the form `t=,sha256=`:
```http theme={null}
Leeway-Signature: t=1492774577000,sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```
* `t` — the timestamp the event was signed at, in **milliseconds** since the Unix epoch.
* `sha256` — the computed HMAC-SHA256 hash, hex-encoded.
> [!NOTE]
>
> For backward compatibility the same value is also sent in a `Leeway_Signature` header (with an
> underscore). Prefer the canonical `Leeway-Signature`; some proxies strip headers that contain
> underscores.
### Verifying the signature
To verify, recompute the HMAC over `timestamp + "." + rawBody` with your secret and compare it
to the `sha256` value from the header.
> [!WARNING]
>
> Compute the HMAC over the **raw request body bytes**, exactly as received — do not re-serialize
> the parsed JSON. Re-serializing can reorder keys or change whitespace and break the comparison.
```javascript Node.js theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* @param {string} header - the `Leeway-Signature` header value
* @param {string} secret - the webhook's signing secret
* @param {string} rawBody - the raw request body, exactly as received
*/
function isValidSignature(header, secret, rawBody) {
// Parse "t=,sha256="
const parts = Object.fromEntries(header.split(',').map((kv) => kv.trim().split('=')));
const { t: timestamp, sha256: received } = parts;
if (!timestamp || !received) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// Constant-time comparison to avoid timing attacks
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
// Replay protection: reject events older than 5 minutes
const ageMs = Math.abs(Date.now() - Number(timestamp));
return ageMs < 5 * 60 * 1000;
}
```
> [!NOTE]
>
> Always use a **constant-time** comparison (e.g. `timingSafeEqual`) rather than `===` to avoid
> [timing attacks](https://en.wikipedia.org/wiki/Timing_attack).
### Replay protection
The signed timestamp also lets you reject **replayed** events: compare `t` to the current time
and discard anything outside a tolerance window you control (5 minutes is a reasonable default,
as shown above). This prevents an attacker from re-sending a previously valid, intercepted
request.
## Delivery & retries
### What counts as a successful delivery
When Tomorro sends an event, your endpoint must:
* respond with a **`2xx`** HTTP status code, and
* do so within the **3-second** timeout.
Any other status code, or a response that takes longer than the timeout, is treated as a
**failed** delivery.
> [!TIP]
>
> Acknowledge fast, process later. Return `2xx` as soon as you've received the event, then do
> any heavy work asynchronously. This keeps you comfortably under the 3-second timeout.
### Retry policy
If a delivery fails, Tomorro automatically retries it:
* Failed events are re-queued with a **5-minute delay** between attempts.
* Tomorro retries up to **10 times** for a given event.
* If every attempt fails, Tomorro stops retrying and **disables the webhook**. A disabled
webhook receives no further events until you re-enable it from the
[Webhooks settings](https://app.tomorro.com/settings/integrations?integration=webhook).
> [!NOTE]
>
> Because retries are spaced 5 minutes apart, your endpoint may receive the same event more than
> once. Make your handler **idempotent** — deduplicate using the `eventId` field so reprocessing
> a delivery has no side effects.
> [!NOTE]
>
> Exact timeout and retry values may change over time as we tune delivery.
# Non-Technical scoping
Source: https://alpha.developer.tomorro.com/guides/backup-your-signed-contracts/non-technical-scoping
Back up your signed contracts: what the business owner decides, and the brief to hand to IT
**Goal:** every contract signed in Tomorro is also saved as a PDF in your own storage (SharePoint, Google Drive, Box, S3...), with no download by hand. Your teams keep a copy where they already work, and your IT keeps a backup it controls.
**TL;DR:** one flow, plus a one-off export, run by a no-code tool (Make, n8n, Zapier) or a few lines of code.
* **Flow 0: Initialisation.** Run once: every contract already signed in Tomorro is copied to your storage.
* **Flow 1: Contract signed.** As soon as a contract is signed in Tomorro, its signed PDF lands in your storage.
Two ways to organise the backup:
* **Simple (recommended):** every signed contract lands in **one folder**, with a clear file name and, if your storage supports it, metadata columns (contract type, counterparty, signature date) to sort and filter.
* **Advanced:** the backup **mirrors your Tomorro folder tree**. Possible, but much more work to build and to maintain: Tomorro does not give the folder path of a contract, so IT has to rebuild your Tomorro storage rules in the integration.
**For the business owner.** A few decisions, no technical skills needed. Fill in the brief below and send it to your IT team or integrator: with the [Technical recipes](/guides/backup-your-signed-contracts/technical-recipes), it has everything they need.
## Brief for your IT team
Copy this brief, fill in the blanks, and send it to your IT team or integrator. **It is all they need from you.**
```markdown Brief for IT theme={null}
Subject: Tomorro signed contracts backup — ready to build
Hi,
I have done the business part of the Tomorro signed contracts backup. Over to you:
everything you need to build it is below.
1. Scope
- Storage: ______________________ (e.g. SharePoint, site "Legal", library "Contracts")
- Contracts to back up: ______________________ (e.g. every signed contract / only some contract types)
- Contracts already signed (Flow 0): yes / no
- Organisation: simple (one folder) / advanced (mirror the Tomorro folders)
2. File name and metadata
- File name: ______________________
- Metadata columns: ______________________ -> ______________________
- ______________________ -> ______________________
- ______________________ -> ______________________
3. Timing
- Flow 0 - Initialisation: ______________________ (e.g. once, before go-live)
- Flow 1 - Contract signed: ______________________ (e.g. as soon as the contract is signed)
4. Done on my side
- [ ] Destination folder created, write access given to the integration account
- [ ] Advanced only: list of our Tomorro storage rules, per contract type
Your part (all in the technical recipes):
- Technical recipes: https://alpha.developer.tomorro.com/guides/backup-your-signed-contracts/technical-recipes
- API key and webhook: Tomorro > Settings > Integrations, from a dedicated superadmin account
Thanks!
```
## Help to complete the brief
### 1 - Scope
| Question | Example answer | Why it matters |
| - | - | - |
| Where should the copies go? | SharePoint, site "Legal", library "Contracts" | IT needs access to it |
| Which contracts? | Every signed contract | Or only some contract types: the integration skips the others |
| Back up the contracts already signed? | Yes | If not, Flow 0 is not needed |
| One folder, or the Tomorro folder tree? | One folder, with metadata columns | The tree is much harder to build and to keep in sync (see below) |
> **Why mirroring the Tomorro folders is harder**
>
> In Tomorro, the folder of a contract comes from **storage rules** on each contract type (e.g. "Contract type = NDA and counterparty country = France → Legal / NDA / France"). The API does not return a folder path for a contract, so the integration has to apply the same rules itself, from the contract's data.
>
> Consequences: every rule has to be listed and rebuilt in the integration, and every time a rule changes in Tomorro, the integration has to change too.
>
> Most storage tools (SharePoint, Google Drive) give a better result with **one folder and metadata columns**: sort, filter and save views by contract type, counterparty or date, with nothing to maintain.
### 2 - File name and metadata
How each signed PDF is named, and which contract data goes along with it.
> [!NOTE]
>
> Example only: choose the name and the columns that fit your storage.
| Tomorro data | In the storage | Rule |
| - | - | - |
| Contract name + contract id | File name | e.g. `NDA - Acme - 7d12db6f.pdf`: unique, even for two contracts with the same name |
| Contract type | Column "Contract type" | Copied as is |
| Counterparty | Column "Counterparty" | Copied as is |
| Signature date | Column "Signature date" | Copied as is (contracts signed after go-live) |
| Link to the contract | Column "Tomorro link" | Opens the contract in Tomorro |
### 3 - Timing
When should each automation run, and what starts it?
> [!NOTE]
>
> Example only: choose the timing that fits your process.
| Flow | What starts it | When it runs |
| - | - | - |
| Flow 0 - Initialisation | Run by hand | Once, before go-live |
| Flow 1 - Contract signed | Every contract signed in Tomorro | As soon as the contract is signed |
### 4 - Checklist
* [ ] Scope (1), file name and metadata (2) and timing (3) validated
* [ ] Destination folder (and metadata columns) created in the storage, write access for the integration
* [ ] Advanced only: the storage rules of each contract type listed (the storage settings of each contract type, see [Configure contract storage](https://help.tomorro.com/en/articles/13459997-configure-contract-storage) in the help center)
* [ ] An IT contact or integrator to build the flows, and the brief sent to them
# Technical recipes
Source: https://alpha.developer.tomorro.com/guides/backup-your-signed-contracts/technical-recipes
Back up your signed contracts: prerequisites, the flows and the API reference for your IT team
Everything runs on one Tomorro webhook and two REST endpoints. How you write to the storage depends on your storage (SharePoint, Google Drive, Box, S3...).
```mermaid theme={null}
sequenceDiagram
autonumber
actor U as Tomorro user
participant T as Tomorro
participant I as Integration
customer middleware
participant S as Storage
SharePoint, Drive, S3...
Note over U,S: Prerequisites: "automation" superadmin account, API key, contractSigned webhook created from that account, write access to the storage
rect rgb(232, 244, 253)
Note over U,S: Flow 0: Initialisation, run once by hand (Tomorro -> Storage)
loop Each page of signed contracts
I->>T: GET /v2/contracts?status=signed&limit=50
T-->>I: contracts + pagination.next_cursor
loop Each contract not in the storage yet
I->>T: GET /v2/contracts/{id}/signed-files
T-->>I: downloadUrl (valid 30 s)
I->>S: Upload the signed PDF
end
end
end
rect rgb(232, 244, 253)
Note over U,S: Flow 1: Contract signed (Tomorro -> Storage)
U->>T: Last signatory signs
T->>I: POST webhook contractSigned
header Leeway-Signature, timeout 3 s
I-->>T: 200 OK
I->>T: GET /v2/contracts/{id}/signed-files
T-->>I: downloadUrl (valid 30 s)
opt Metadata or advanced folder tree
I->>T: GET /v2/contracts/{id}
T-->>I: contract type, counterparty, fields
Note over I: Advanced: apply the Tomorro storage rules to find the folder
end
I->>S: Upload the signed PDF (+ metadata)
end
```
## 1. Technical prerequisites
**1. 1.1 - Create Tomorro superadmin account**
An existing admin creates a dedicated **superadmin** account from **Settings > Members** (e.g. `automation@customer.com`), not a personal account.
* The API key and the webhook are bound to a **member**, not to the organization: they only see the contracts that member can see.
* A **superadmin** sees **every contract** of the workspace, in every folder. Its webhook fires for every signed contract and its API key can download every signed file, without the account being added as a participant.
* With an **admin** account, the webhook only fires for the contracts it takes part in (or that sit in no folder), and the backup misses the others. Use a superadmin.
* Recommended: no personal mailbox behind it, and exclude it from Tomorro notification settings.
**2. 1.2 - API key**
Generate the API key from that superadmin account. It does not expire. It is sent on every REST call in the `x-api-key` header: [Step 1: Get your API key](/quickstart)
**3. 1.3 - contractSigned webhook**
**Logged in as the superadmin account**, create a webhook with the trigger `contractSigned` and the URL of your integration: [Webhooks](/webhooks/overview). Keep its signing secret to verify every delivery ([Technical documentation](/webhooks/technical-documentation)).
**4. 1.4 - Storage**
Out of scope (depends on the storage). Create the destination folder (and the metadata columns, see [Non-Technical scoping](/guides/backup-your-signed-contracts/non-technical-scoping)), and give the integration write access to it.
## 2. Flows
### Flow 0 - \[Tomorro → Storage] Initialisation
**1. Step 0 - Triggered by hand**
Run once, when the integration goes live: the webhook of Flow 1 only fires for contracts signed **after** it is created. Re-running it is safe: contracts already in the storage are skipped.
**2. Step 1 - List the signed contracts**
```http theme={null}
GET https://api.tomorro.com/v2/contracts?status=signed&limit=50
x-api-key:
```
Response `200`:
```json theme={null}
{
"data": [
{
"id": "7d12db6f-36a6-4dc5-b022-66471ff5xxxc",
"name": "NDA - Acme",
"status": "signed",
"counterparty": { "id": "...", "name": "Acme" },
"contractType": { "id": "...", "name": "NDA" },
"contractUrl": "https://app.tomorro.com/acme/project/7d12db6f-36a6-4dc5-b022-66471ff5xxxc"
}
],
"pagination": { "limit": 50, "next_cursor": "eyJpZCI6...", "has_next": true, "has_previous": false }
}
```
> **Notes**
>
> * Up to 50 contracts per page: while `pagination.has_next` is `true`, call again with `after=`.
> * Only some contract types: add `contractTypeId=` (one type per call).
> * Skip a contract whose file is already in the storage (file name contains the contract id, see step 3).
**3. Steps 2 and 3 - Same as Flow 1**
For each contract: get the signed files, upload them (Flow 1, steps 1 and 3).
### Flow 1 - \[Tomorro → Storage] Contract signed
**Simple backup (recommended)**: every signed contract lands in one folder.
**1. Step 0 - Receive the contractSigned webhook**
Verify the `Leeway-Signature` header, answer `2xx` **within 3 seconds**, then do the work asynchronously ([Technical documentation](/webhooks/technical-documentation)).
```json theme={null}
{
"eventId": "b21213e3-8a9a-4e04-9bfc-c4e53f123xxx",
"webhookId": "2a76094c-1f2e-48c8-a47f-1add41234xxx",
"createdAt": "2026-10-02T14:55:16.280Z",
"eventType": "contractSigned",
"data": {
"contract": {
"id": "7d12db6f-36a6-4dc5-b022-66471ff5xxxc",
"name": "NDA - Acme",
"status": "signed",
"signatureDate": "2026-10-02T14:55:10.000Z",
"typeId": "...",
"externalCompany": { "id": "...", "name": "Acme" }
},
"signatories": [ { "id": "...", "name": "Jane Doe", "email": "jane.doe@acme.com" } ]
}
}
```
> **Notes**
>
> * Keep `data.contract.id`, and `name`, `signatureDate`, `typeId`, `externalCompany.name` for the file name and the metadata.
> * Only some contract types: skip the event when `data.contract.typeId` is not one of them.
> * **Deduplicate on `eventId`**: a delivery that fails is retried (up to 10 times, 5 minutes apart), so the same event can arrive more than once.
**2. Step 1 - Get the signed files**
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}/signed-files
x-api-key:
```
Response `200`:
```json theme={null}
{
"data": [
{
"id": "550e8400-...",
"filename": "NDA - Acme - signed.pdf",
"contentType": "application/pdf",
"downloadUrl": "https://s3...",
"expiresAt": "2026-10-02T14:55:46.000Z",
"contractUrl": "https://app.tomorro.com/acme/project/7d12db6f-..."
}
]
}
```
> **Notes**
>
> * One entry per signed document: the main document, then the annexes signed in the same bundle, in order.
> * `downloadUrl` is a pre-signed URL valid **30 seconds**, see `expiresAt`. Download straight away (no auth header) and do not store the URL. Re-call the endpoint for a fresh one.
> * `409` when the contract has no signed file yet; `404` for an unknown contract.
**3. Step 2 - Read the contract metadata (optional)**
Only needed for metadata the webhook does not carry (contract type name, smart fields), or for the advanced folder tree below.
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}
x-api-key:
```
Returns `contractType` (`id`, `name`), `counterparty`, `fields` (smart field values) and `contractUrl`.
**4. Step 3 - Upload to the storage**
Out of scope (depends on the storage). Upload each file to the destination folder.
* **File name**: include the contract id (e.g. `NDA - Acme - 7d12db6f.pdf`): two contracts can share a name, and a re-run overwrites instead of duplicating.
* **Metadata**: contract type, counterparty, signature date, `contractUrl`... as columns, if the storage supports them.
* **Several files**: keep the order of step 1, e.g. `NDA - Acme - 7d12db6f.pdf`, then `NDA - Acme - 7d12db6f - Annex 1.pdf`.
> [!TIP]
>
> **Storage unreachable**: retry with backoff (Make, n8n and Zapier have a retry-on-error setting), and log the failed uploads to a channel someone reads (email, Slack). Answering `2xx` to the webhook first means Tomorro does not retry for you.
**Advanced: mirror the Tomorro folder tree.** Same flow, the folder is computed in step 2 instead of being fixed.
> [!WARNING]
>
> Tomorro does **not** return the folder path of a contract. `GET /v2/contracts/{id}` gives a `folderId`, but no public endpoint turns it into a folder name or path. The folder comes from the **storage rules** of the contract type: the integration has to apply the same rules itself, and be updated every time a rule changes in Tomorro. Start with the simple backup unless you have a strong reason not to.
**1. Step 2.1 - List your storage rules**
Once, from the storage settings of each contract type in Tomorro ([Configure contract storage](https://help.tomorro.com/en/articles/13459997-configure-contract-storage)), write each rule as a condition on the contract's data and a target path. For example:
| Contract type | Condition | Target folder |
| - | - | - |
| NDA | Counterparty country = France | `Legal/NDA/France` |
| NDA | Any other | `Legal/NDA/International` |
| Employment contract | - | `HR/Employment contracts/` |
**2. Step 2.2 - Compute the folder**
Read the contract (step 2) and evaluate the rules of its `contractType.id` against `counterparty` and `fields` (smart field values) to get the target path. No rule matches: fall back to a single "To sort" folder rather than dropping the file.
**3. Step 2.3 - Create the folder if needed**
Out of scope (depends on the storage). Create each missing level of the path, then upload as in step 3.
## 3. Technical references
Reference material: conventions, the endpoints, the webhook and error handling. Read it once, then come back to it while implementing the flows above.
### 3.1 - API conventions
* **REST only.** The whole integration runs on the public REST API and one webhook.
* **Base URL**: `https://api.tomorro.com/v2`
* **Authentication**: `x-api-key: ` on every call. A missing or unknown key returns 401.
* **Versioning**: optional `tomorro-version` header, defaults to the latest version.
* **Rate limit**: a `429` means too many calls in a short time. Retry with backoff, especially during Flow 0.
* **Sandbox**: available on request. It is an empty sandbox, not a copy of your production workspace.
* **Response envelope**: every **successful** JSON response is wrapped in a `data` object (errors come as `{ error, meta }`, see 3.4).
* **Full reference**: [Tomorro API](/api-reference/introduction)
### 3.2 - Endpoints
| Endpoint | Use in this integration |
| - | - |
| `GET /v2/contracts?status=signed` | Flow 0: every signed contract. `limit` 1 to 50, cursor pagination (`after`), `contractTypeId` to narrow down. |
| `GET /v2/contracts/{id}/signed-files` | Flows 0 and 1: the signed PDFs, with a download URL valid 30 seconds. |
| `GET /v2/contracts/{id}` | Flow 1, optional: contract type, counterparty, smart field values, `folderId`. |
### 3.3 - The contractSigned webhook
* **When it fires**: when a contract becomes `signed`, whatever the signature tool: Tomorro signature, DocuSign, or a signed version pushed by a third-party signature tool.
* **When it does not**: contracts signed before the webhook was created, and contracts **imported directly as signed** ([bulk import](https://help.tomorro.com/en/articles/3727366-import-contracts-in-bulk), [store a signed document](https://help.tomorro.com/en/articles/10493914-store-a-signed-document)). Flow 0 covers both: re-run it after a bulk import.
* **Payload**: `data.contract` (`id`, `name`, `status`, `signatureDate`, `typeId`, `externalCompany`, `attributes`...) and `data.signatories`. No folder.
* **Delivery**: `2xx` within 3 seconds, otherwise retried up to 10 times, 5 minutes apart; after the 10th failure the webhook is **disabled** and has to be re-enabled in the settings. See [Delivery & retries](/webhooks/technical-documentation).
* **Security**: every delivery is signed (`Leeway-Signature` header). Verify it before calling the API.
### 3.4 - Error handling
Every endpoint follows the standard error format of the REST API.
**Error envelope**
```json theme={null}
{
"error": {
"statusCode": "409",
"errorId": "optional, present on some domain errors",
"message": "Human readable message"
},
"meta": {
"timestamp": "2026-10-02T10:09:22.634Z",
"requestId": "req_Ab12Cd34"
}
}
```
* `error.statusCode` is a **string**, not a number.
* `meta.requestId` is the value to quote to Tomorro support when reporting a failing call. Log it.
| Code | Meaning | Retry? |
| - | - | - |
| `401` | Missing or unknown `x-api-key`. | No |
| `403` | The API key's account cannot see this contract: use a superadmin account (1.1). | No |
| `404` | Unknown contract. | No |
| `409` | `signed-files`: no signed file on this contract (not signed yet). | No |
| `429` | Rate limit exceeded. | Yes, with backoff |
| `500` / timeout | Internal error. | Yes, with backoff |
### 3.5 - Edge cases
* **Duplicates**: name each file with the contract id and deduplicate webhook deliveries on `eventId`: a retried event or a re-run of Flow 0 overwrites instead of adding a copy.
* **Webhook disabled** after 10 failed deliveries: events fired meanwhile are not replayed. Re-enable it, then re-run Flow 0 to catch up (already saved contracts are skipped).
* **Contract deleted or canceled in Tomorro after signature**: the backup keeps its copy. Nothing is removed from the storage.
* **Amendment**: a new contract in Tomorro, so a new file with its own id.
* **Unsigned contracts** (draft, negotiating): out of scope, their document is not a final PDF yet.
* **Signature certificate**: not part of `signed-files`. Download it from the contract in Tomorro if your backup needs it.
# Full cycle example
Source: https://alpha.developer.tomorro.com/guides/connect-your-own-signature-tool/full-cycle-example
Connect your own signature tool: a working Yousign example with two Make scenarios
[Make](https://www.make.com) is a no-code automation platform. A **blueprint** is a Make scenario exported as JSON: import it (*Import blueprint*), connect your own accounts, and every module comes back configured.
The two blueprints below run every flow of this guide with Yousign: Tomorro → Yousign (Flows 1 and 3) and Yousign → Tomorro (Flows 2 and 4). Use them as a **head start**: import them if you run Make, read them as a reference implementation if you build on n8n, custom code or another signature tool.
> **[Get the 2 Make blueprints](https://drive.google.com/drive/folders/1YXxdcyQ7cBKlkSu5FGwl0nZx5Vk5-t0U)**
>
> The *3rd party* files: Tomorro → Third-Party (Flows 1 and 3) and Third-Party → Tomorro (Flows 2 and 4).
> **Notes**
>
> The two Make blueprints above are a working example, not a ready-made integration. They make choices you may want to change for your own setup:
>
> * **Yousign sandbox**: every Yousign call targets `api-sandbox.yousign.app`. Switch to `api.yousign.app` for production.
> * **Signature level**: `electronic_signature` (simple electronic signature), with `signature_authentication_mode: no_otp`, so signers get no SMS or email code.
> * **Language and time zone**: signers, emails and audit trail in English (`locale: en`), time zone `Europe/Paris`.
> * **Delivery**: `delivery_mode: email`, Yousign sends the invitations and reminders to the signatories.
> * **Signature zones**: only Tomorro fields of type `signature` are sent, `initials` are ignored. A signatory with no zone in Tomorro gets one at the bottom of the last page of the main document.
> * **Webhook secrets**: the Tomorro and Yousign webhook secrets are read from a Make data store.
## Yousign Full Demo
[Third-party signature demo: Yousign and Make](https://www.loom.com/embed/e511cf7fd89645a4ba3a2b03d8b877dd)
### Flow 1 - \[Tomorro → Yousign] Signature is requested
**1. Step 0 - Triggered by a user on Tomorro**
Triggered when a user clicks "Send for signature" on a Tomorro contract set to the "Other signature tool" option (after the approval workflow if there is one).
**2. Step 1 - Receive the signature.requested webhook**
*Make module 1*
Keep at least `data.contractId`, it is the key for every following call.
**3. Step 1.1 - Verify the Leeway signature**
*Make modules 2, 3*
The secret is read from the Make data store, key `tomorro_webhook_secret`. Inputs: the raw body, the request headers and the secret.
```javascript theme={null}
const crypto = require('crypto');
const { rawBody, headers, secret } = input;
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
const header = (headers || []).find((h) => ['leeway-signature', 'leeway_signature'].includes(String(h.name).toLowerCase()));
if (!header) return { valid: false, reason: 'missing Leeway-Signature header' };
const parts = Object.fromEntries(String(header.value).split(',').map((p) => p.trim().split('=')));
const { t, sha256 } = parts;
if (!t || !sha256) return { valid: false, reason: 'malformed Leeway-Signature header' };
if (Math.abs(Date.now() - Number(t)) > REPLAY_WINDOW_MS) return { valid: false, reason: 'timestamp outside the 5 minute window' };
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const matches = expected.length === sha256.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sha256));
if (!matches) return { valid: false, reason: 'signature mismatch' };
return { valid: true, event: JSON.parse(rawBody) };
```
**4. Step 1.2 - Route the event**
*Make module 4*
Continue only when `valid` is `true` and `test` is not `true`. Route on `eventType`: `signature.requested` → this flow, `signature.canceled` → Flow 3.
**5. Step 2 - Retrieve the files and signature information**
*Make module 5*
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}/signing-files
x-api-key:
```
**6. Step 3 - Retrieve more contract information (optional)**
*Make module 6*
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}
x-api-key:
```
**7. Step 4 - Work in Yousign**
**8. Step 4.1 - Build the Yousign signature request**
*Make module 7*
Inputs: `contractId`, `contractName` and `signatureNote` from the webhook, `firstSigningParty` from step 2. `external_id` = `contractId` is the link used by Flows 2, 3 and 4.
```javascript theme={null}
const { contractId, contractName, signatureNote, firstSigningParty } = input;
const body = {
name: String(contractName || 'Tomorro contract').slice(0, 128),
delivery_mode: 'email',
timezone: 'Europe/Paris',
ordered_signers: Boolean(firstSigningParty),
external_id: contractId,
audit_trail_locale: 'en',
};
if (signatureNote) body.email_custom_note = String(signatureNote).slice(0, 500);
return { body: JSON.stringify(body) };
```
**9. Step 4.2 - Create the Yousign signature request**
*Make module 8*
```http theme={null}
POST https://api-sandbox.yousign.app/v3/signature_requests
Authorization: Bearer
Content-Type: application/json
{
"name": "",
"delivery_mode": "email",
"timezone": "Europe/Paris",
"ordered_signers": true,
"external_id": "",
"audit_trail_locale": "en",
"email_custom_note": ""
}
```
**10. Step 4.3 - Download each file to sign**
*Make modules 9, 10*
Iterate over `data.files` from step 2, then download each `downloadUrl` (no auth header, valid 5 minutes).
```http theme={null}
GET
```
**11. Step 4.4 - Upload each document to Yousign**
*Make module 11*
```http theme={null}
POST https://api-sandbox.yousign.app/v3/signature_requests/{signatureRequestId}/documents
Authorization: Bearer
Content-Type: multipart/form-data
file = , filename
nature = signable_document
```
**12. Step 4.5 - Map each Tomorro file to its Yousign document**
*Make module 12*
Aggregate one entry per file, joined with `,`:
```json theme={null}
{"fileId":"","documentId":""}
```
**13. Step 4.6 - Build the Yousign signers**
*Make module 13*
Inputs: `data` from step 2 (`files`, `signatories`, `firstSigningParty`) and the mapping from step 4.5. Orders the signers, converts the Tomorro field positions into Yousign coordinates and cleans the names.
```javascript theme={null}
const { signingFiles, documents } = input;
const documentIdByFileId = Object.fromEntries(
JSON.parse(`[${documents}]`).map(({ fileId, documentId }) => [fileId, documentId]),
);
const files = signingFiles.files || [];
const signatories = signingFiles.signatories || [];
// Yousign signs in creation order: members ranked by order, guests on the side given by firstSigningParty
const members = signatories.filter((s) => s.type === 'member').sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const guests = signatories.filter((s) => s.type !== 'member');
const ordered = signingFiles.firstSigningParty === 'guest' ? [...guests, ...members] : [...members, ...guests];
// Yousign minimum signature field size
const WIDTH = 85;
const HEIGHT = 37;
const clamp = (value, max) => Math.max(0, Math.min(Math.round(value), Math.floor(max)));
const mainFile = files.find((f) => f.isMain) || files[0];
const signers = ordered.map((signatory, index) => {
const fields = files.flatMap((file) =>
(file.fields || [])
.filter((f) => f.signatoryId === signatory.id && f.type === 'signature' && documentIdByFileId[file.id])
.map((f) => {
const page = file.pages[f.pageIndex];
return {
document_id: documentIdByFileId[file.id],
type: 'signature',
page: f.pageIndex + 1,
x: clamp((f.positionX / 100) * page.width, page.width - WIDTH),
y: clamp((f.positionY / 100) * page.height, page.height - HEIGHT),
width: WIDTH,
height: HEIGHT,
};
}),
);
// No zone placed in Tomorro: Yousign needs one, so drop it at the bottom of the main document's last page
if (!fields.length && mainFile) {
const pageNumber = mainFile.pages.length;
const page = mainFile.pages[pageNumber - 1];
fields.push({
document_id: documentIdByFileId[mainFile.id],
type: 'signature',
page: pageNumber,
x: clamp(40 + (index % 4) * 130, page.width - WIDTH),
y: clamp(page.height - 80 - Math.floor(index / 4) * 60, page.height - HEIGHT),
width: WIDTH,
height: HEIGHT,
});
}
// name can be "" for a guest invited by email; Yousign rejects names with characters like . or _
const clean = (value) => value.replace(/[^\p{L}0-9`'() -]/gu, ' ').replace(/\s+/g, ' ').trim();
const fallback = clean(signatory.email.split('@')[0]) || 'Signataire';
const [firstName, ...lastNames] = (clean(signatory.name || '') || fallback).split(' ');
return {
email: signatory.email,
body: JSON.stringify({
info: {
first_name: firstName,
last_name: lastNames.join(' ') || firstName,
email: signatory.email,
locale: 'en',
},
signature_level: 'electronic_signature',
signature_authentication_mode: 'no_otp',
fields,
}),
};
});
return { signers };
```
**14. Step 4.7 - Add each signer to Yousign**
*Make modules 14, 15, 16*
Iterate over `signers` from step 4.6, one call per signer, in order:
```http theme={null}
POST https://api-sandbox.yousign.app/v3/signature_requests/{signatureRequestId}/signers
Authorization: Bearer
Content-Type: application/json
{
"info": { "first_name": "Jane", "last_name": "Doe", "email": "jane.doe@acme.com", "locale": "en" },
"signature_level": "electronic_signature",
"signature_authentication_mode": "no_otp",
"fields": [
{ "document_id": "", "type": "signature", "page": 1, "x": 241, "y": 227, "width": 85, "height": 37 }
]
}
```
**15. Step 4.8 - Activate the signature request**
*Make module 17*
Yousign sends the invitations to the signatories.
```http theme={null}
POST https://api-sandbox.yousign.app/v3/signature_requests/{signatureRequestId}/activate
Authorization: Bearer
Content-Type: application/json
{}
```
### Flow 2 - \[Yousign → Tomorro] Signature is completed
**1. Step 0 - Receive the Yousign webhook**
*Make module 1*
Subscribe this webhook in Yousign to `signature_request.done`, and to the events of Flow 4.
**2. Step 0.1 - Verify the Yousign signature**
*Make modules 2, 3*
The secret is read from the Make data store, key `yousign_webhook_secret`. Inputs: the raw body, the request headers and the secret.
```javascript theme={null}
const crypto = require('crypto');
const { rawBody, headers, secret } = input;
const header = (headers || []).find((h) => String(h.name).toLowerCase() === 'x-yousign-signature-256');
if (!header) return { valid: false, reason: 'missing X-Yousign-Signature-256 header' };
const received = String(header.value).trim();
const expected = `sha256=${crypto.createHmac('sha256', secret).update(rawBody).digest('hex')}`;
const matches = expected.length === received.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!matches) return { valid: false, reason: 'signature mismatch' };
return { valid: true, event: JSON.parse(rawBody) };
```
**3. Step 0.2 - Route the event**
*Make module 4*
Continue only when `valid` is `true`. This flow takes `event_name` = `signature_request.done` with a `data.signature_request.external_id` (the Tomorro `contractId`). The other events go to Flow 4.
**4. Step 0.3 - Download each signed document from Yousign**
*Make modules 5, 6*
Iterate over `data.signature_request.documents`, keep `nature` = `signable_document`:
```http theme={null}
GET https://api-sandbox.yousign.app/v3/signature_requests/{signatureRequestId}/documents/{documentId}/download
Authorization: Bearer
```
**5. Step 1 - Get an upload URL, one per PDF**
*Make module 7*
```http theme={null}
POST https://api.tomorro.com/v2/files/upload-url
x-api-key:
Content-Type: application/json
{ "contentType": "application/pdf" }
```
**6. Step 2 - Upload the PDF to data.url**
*Make module 8*
No auth header, raw bytes of the PDF downloaded in step 0.3.
```http theme={null}
PUT
Content-Type: application/pdf
```
**7. Step 3 - Push the contract to Tomorro**
**8. Step 3.1 - Collect the uploaded files**
*Make module 9*
Aggregate one row per PDF, joined with a new line:
```text theme={null}
|
```
**9. Step 3.2 - Build the signed-version body**
*Make module 10*
Inputs: the rows from step 3.1, `data.signature_request.completed_at` and `event_time` from the Yousign webhook.
```javascript theme={null}
const { uploads, completedAt, eventTime } = input;
const files = uploads.split('\n').filter(Boolean).map((row) => {
const [key, ...rest] = row.split('|');
const name = rest.join('|').trim() || 'Signed document.pdf';
return { key, name: /\.pdf$/i.test(name) ? name : `${name}.pdf`, contentType: 'application/pdf' };
});
// event_time is a unix timestamp; Tomorro refuses a signatureDate in the future
const toDate = (value) => new Date(/^\d+$/.test(String(value)) ? Number(value) * 1000 : value);
const signedAt = toDate(completedAt || eventTime);
const signatureDate = new Date(Math.min(signedAt.getTime(), Date.now())).toISOString();
return { body: JSON.stringify({ files, signatureDate }) };
```
**10. Step 3.3 - Push the signed version**
*Make module 11*
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{external_id}/signed-version
x-api-key:
Content-Type: application/json
{
"files": [
{ "key": "", "name": "NDA - Acme.pdf", "contentType": "application/pdf" }
],
"signatureDate": "2026-09-07T10:15:00.000Z"
}
```
**11. Step 3.4 - Accept 409 ContractAlreadySigned**
*Make module 12*
Inputs: status code and body of step 3.3 (the module runs with *Evaluate all states as errors* off).
```javascript theme={null}
const { statusCode, body } = input;
const status = Number(statusCode);
if (status >= 200 && status < 300) return { outcome: 'signed' };
const parsed = typeof body === 'string' ? (() => { try { return JSON.parse(body); } catch { return {}; } })() : body || {};
// A previous delivery of the same Yousign webhook already pushed the signed version
if (status === 409 && parsed.error?.errorId === 'ContractAlreadySigned') return { outcome: 'alreadySigned' };
throw new Error(`signed-version failed with ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`);
```
### Flow 3 - \[Tomorro → Yousign] Signature canceled in Tomorro
**1. Step 0 - A user cancels the signature on Tomorro**
**2. Step 1 - Receive the signature.canceled webhook**
*Make modules 1, 2, 3, 4*
Same webhook, same verification and same router as Flow 1 step 1. This route takes `eventType` = `signature.canceled`.
**3. Step 2 - Cancel in Yousign**
**4. Step 2.1 - Find the Yousign signature request**
*Make module 18*
Look it up by `external_id` = `contractId`, among the requests still running:
```http theme={null}
GET https://api-sandbox.yousign.app/v3/signature_requests?external_id[eq]={contractId}&status[in]=ongoing,approval
Authorization: Bearer
```
**5. Step 2.2 - Build the Yousign cancel body**
*Make module 19*
Input: `data.cancelReason` from the webhook.
```javascript theme={null}
const { cancelReason } = input;
const body = { reason: 'contractualization_aborted' };
if (cancelReason) body.custom_note = String(cancelReason).slice(0, 500);
return { body: JSON.stringify(body) };
```
**6. Step 2.3 - Cancel each matching request**
*Make modules 20, 21*
Iterate over `data` from step 2.1:
```http theme={null}
POST https://api-sandbox.yousign.app/v3/signature_requests/{signatureRequestId}/cancel
Authorization: Bearer
Content-Type: application/json
{ "reason": "contractualization_aborted", "custom_note": "" }
```
Yousign then fires `signature_request.canceled`, which reaches Flow 4: its `cancel-signatures` call answers `400`, accepted as expected.
### Flow 4 - \[Yousign → Tomorro] Signature canceled in Yousign
**1. Step 0 - Receive the Yousign webhook**
*Make modules 1, 2, 3, 4*
Same webhook, same verification and same router as Flow 2 step 0. This route takes `event_name` in `signature_request.declined`, `.rejected`, `.expired`, `.canceled`, `.deleted`, `.permanently_deleted`, with a `data.signature_request.external_id`.
**2. Step 1 - Cancel the signature in Tomorro**
**3. Step 1.1 - Build the cancel-signatures body**
*Make module 13*
Input: `event_name` from the Yousign webhook.
```javascript theme={null}
const { eventName } = input;
const reasons = {
'signature_request.declined': 'Refused by a signatory in Yousign',
'signature_request.rejected': 'Rejected by an approver in Yousign',
'signature_request.expired': 'Signature request expired in Yousign',
'signature_request.canceled': 'Signature request canceled in Yousign',
'signature_request.deleted': 'Signature request deleted in Yousign',
'signature_request.permanently_deleted': 'Signature request deleted in Yousign',
};
return { body: JSON.stringify({ cancelReason: reasons[eventName] || 'Signature stopped in Yousign' }) };
```
**4. Step 1.2 - Cancel the signature**
*Make module 14*
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{external_id}/cancel-signatures
x-api-key:
Content-Type: application/json
{ "cancelReason": "Refused by a signatory in Yousign" }
```
**5. Step 1.3 - Accept 400**
*Make module 15*
Inputs: status code and body of step 1.2. A `400` means the contract is no longer in `signing` (echo of Flow 3, or already signed).
```javascript theme={null}
const { statusCode, body } = input;
const status = Number(statusCode);
if (status >= 200 && status < 300) return { outcome: 'canceled' };
// Contract no longer in signing: already canceled in Tomorro (echo of Flow 3, or a retry that went through) or signed
if (status === 400) return { outcome: 'notInSigning' };
throw new Error(`cancel-signatures failed with ${status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`);
```
**6. Step 2 - Ignore the echo webhook**
Tomorro fires `signature.canceled` back, which reaches Flow 3. Step 2.1 there only searches the `ongoing` and `approval` requests, finds none, and the flow stops.
# Non-Technical scoping
Source: https://alpha.developer.tomorro.com/guides/connect-your-own-signature-tool/non-technical-scoping
Connect your own signature tool: what the integration covers, in five flows
**Scope:** this guide only covers the exchanges between **Tomorro and the integration** (the middleware built by the customer). What the integration does with the third-party signature tool (Yousign, Universign, Adobe Sign, LuxTrust, Oodrive...) is out of scope and entirely on the customer's side.
**TL;DR**
Five flows, all between Tomorro and your middleware:
* **Flow 1: Signature is requested.** A user sends for signature in Tomorro. Tomorro fires `signature.requested`, you fetch the document, fields and signatories, then run the signature in your own tool. **Tomorro sends nothing to the signatories at any point**: your tool owns the whole signer experience, so no double notification.
* **Flow 2: Signature is completed.** Once everyone has signed in your tool, you push the signed PDF back. Tomorro moves the contract to `signed` and runs its post-signature automations.
***
* **Flow 3: Signature canceled in Tomorro.** A user cancels from Tomorro, Tomorro fires `signature.canceled`, you kill the envelope on your side. Nothing to call back.
* **Flow 4: Signature canceled in your tool.** Refused, expired or canceled there: you call `cancel-signatures` and the contract goes back to `draft` or `negotiating`.
***
* **Flow 5: Attachments (optional).** Push the signature certificate, or any supporting document produced by your tool (PDF, Word, Excel...), onto the contract, one file per call.
# Technical recipes
Source: https://alpha.developer.tomorro.com/guides/connect-your-own-signature-tool/technical-recipes
Connect your own signature tool: prerequisites, the five flows and the API reference for your IT team
```mermaid theme={null}
sequenceDiagram
autonumber
actor U as Tomorro user
participant T as Tomorro
participant S3 as Tomorro S3
pre-signed URLs
participant I as Integration
customer middleware
participant P as Third-party tool
OUT OF SCOPE
Note over U,P: Prerequisites: "automation" superadmin account (access to every contract), API key generated and webhook configured
rect rgb(232, 244, 253)
Note over U,P: Flow 1: signature requested (Tomorro -> Third-party)
U->>T: Clicks Send for signature (provider = 'third-party')
Note right of T: Fired after the approval workflow if there is one.
Tomorro sends nothing to the signatories.
T->>I: POST webhook signature.requested
header Leeway-Signature, timeout 3 s
I-->>T: 200 OK
I->>T: GET /v2/contracts/{id}/signing-files
T-->>I: files (downloadUrl) + fields + signatories + firstSigningParty
loop Each file (main document, then annexes)
I->>S3: GET downloadUrl (valid 5 min)
S3-->>I: PDF binary
end
opt Extra contract data
I->>T: GET /v2/contracts/{id}
T-->>I: contract type, smart fields, dates...
end
I->>P: create the envelope, collect the signatures
P-->>I: signed PDF
end
rect rgb(232, 244, 253)
Note over U,P: Flow 2: Signature is complete (Third-party -> Tomorro)
loop Each signed PDF (main document first, then annexes)
I->>T: POST /v2/files/upload-url
T-->>I: url + key
I->>S3: PUT signed PDF binary (direct S3, no Tomorro auth header)
S3-->>I: 200 OK
end
I->>T: POST /v2/contracts/{id}/signed-version
files[] (every key, once) + signatureDate
T-->>I: 204 No Content
Note left of T: Contract moves to signed, post-signature automations run
(notifications, contract.signed webhook, syncs).
U->>T: Consults the contract (signed status + signed PDF)
end
rect rgb(243, 229, 245)
Note over U,P: Flow 3: Signature canceled in Tomorro
U->>T: Cancel the signature process
Note right of T: Contract is already back to 'draft' or 'negotiating'.
T->>I: POST webhook signature.canceled (with cancelReason)
I-->>T: 200 OK
I->>P: Kill the envelope
Note left of I: No call back to Tomorro (cancel-signatures would answer 400). The flow ends here.
end
rect rgb(255, 235, 238)
Note over U,P: Flow 4: Signature canceled in the third-party tool
P-->>I: Refused / expired / canceled
I->>T: POST /v2/contracts/{id}/cancel-signatures
cancelReason (optional)
Note left of T: Contract goes back to 'draft' or 'negotiating', participants notified
T-->>I: 204 No Content
T->>I: POST webhook signature.canceled (echo of this cancel)
I-->>T: 200 OK
Note left of I: Envelope already dead on your side: acknowledge and ignore.
end
rect rgb(245, 229, 164)
Note over U,P: Flow 5 (optional): Attachments (e.g. certificate), any status, never changes it
loop Each attachment (one file per call)
I->>T: POST /v2/files/upload-url (contentType)
T-->>I: url + key
I->>S3: PUT file binary (direct S3, no Tomorro auth header)
S3-->>I: 200 OK
I->>T: POST /v2/contracts/{id}/attachments
key + name + contentType + isSharedWithGuest
T-->>I: 204 No Content
end
end
```
## 1. Prerequisites and configuration
**1. 1.1 - Create Tomorro superadmin account**
An existing admin creates a dedicated **superadmin** account from **Settings > Members** (e.g. `automation@customer.com`), not a personal account.
* The API key is bound to a **member**, not to the organization. Every write performed through the API is attributed to that member and inherits its permissions.
* Consequence:
* the contract activity log will show this account as the author of the signed-file import and of the cancellations. A named account such as "Signature automation" keeps the timeline readable.
* a **superadmin** has access to **every contract** of the workspace: the integration can query any of them (`GET`, `POST`, `PATCH`) without the account being added as a participant. With any other role, the account only sees the contracts it takes part in, and the others are rejected with a `403`. See 3.4.
* Recommended: no personal mailbox behind it, and exclude it from Tomorro notification settings.
**2. 1.2 - API key**
Generate the API key from that superadmin account. It does not expire. It is sent on every REST call in the `x-api-key` header: [Step 1: Get your API key](/quickstart)
**3. 1.3 - Webhook setup in the admin console**
[https://app.tomorro.com/settings/integrations?integration=othersignaturetool](https://app.tomorro.com/settings/integrations?integration=othersignaturetool)
Admin only, and **one endpoint per workspace**: the console edits a single configuration, there is no list and no "Add". Every contract of the organization sent through the third-party option goes to that one URL.
* **Integration name (mandatory)**: the label displayed in the Tomorro UI in place of "Tomorro" or "Docusign" (e.g. `Yousign`, `Universign`...).
* **URL (mandatory)**: the integration endpoint that will receive the events. HTTPS, publicly reachable.
* **Secret**: generated at creation, one single secret signs both events. Revealed on demand in the console. Editing the integration name or the URL does **not** rotate it, so an integration already deployed against it keeps verifying.
* **Events**: `signature.requested` and `signature.canceled` are both subscribed automatically. They are shown for information and cannot be turned off separately. The integration needs both, since a signature that starts must also be able to be canceled.
* **Test buttons**: one per event. Each sends a synthetic payload to the URL to validate reachability and signature verification before going live. See 3.2.4. **Your endpoint must recognize these and ignore them**.
## 2. Flows
### Flow 1 - \[Tomorro → Third-Party] Signature is requested
**1. Step 0 - Triggered by a user on Tomorro**
Triggered when a user clicks "Send for signature" on a Tomorro contract set to the "Other signature tool" option (after the approval workflow if there is one).


**2. Step 1 - Receive the signature.requested webhook**
Keep at least `data.contractId`, it is the key for every following call.
Projected payload:
```json theme={null}
{
"eventId": "9aa01b69-...",
"webhookId": "eeaa7b6d-...",
"createdAt": "2026-09-04T14:32:10.000Z",
"eventType": "signature.requested",
"data": {
"organizationId": "...",
"contractId": "...",
"signatureId": "...",
"contractName": "NDA - Acme",
"counterparty": { "id": "...", "name": "Acme" },
"signatories": [
{ "id": "...", "name": "Jane Doe", "email": "jane.doe@acme.com", "order": 0, "type": "member", "title": "CEO" },
{ "id": "...", "name": "John Doe", "email": "john.doe@external.com", "order": null, "type": "guest", "title": null }
],
"contractMembers": [ { "id": "...", "email": "..." } ],
"signatureNote": "...",
"cancelReason": "..." // only for signature.canceled
}
}
```
> **Notes**
>
> * `signatureNote`: the message typed by the sender, to reuse as the envelope message.
> * `contractMembers`: the Tomorro members of the contract, for instance to copy them on the envelope if your tool supports it.
> * `signatureId`: the Tomorro signature this event is about. A cancel then re-send on the same contract opens a new one, and the `signature.canceled` of a ceremony carries the same `signatureId` as its `signature.requested`, so you can tie them together beyond `contractId`.
**3. Step 2 - Retrieve the files and signature information**
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}/signing-files
x-api-key:
```
Response:
```json theme={null}
{
"data": {
"contractId": "0fb3e924-...",
"firstSigningParty": "member",
"files": [
{
"id": "1ad5b1e9-...",
"isMain": true,
"order": 1,
"filename": "NDA - Acme.pdf",
"contentType": "application/pdf",
"downloadUrl": "https://s3...",
"expiresAt": "2026-09-07T10:15:00.000Z",
"pages": [{ "width": 595.303937007874, "height": 841.889763779528 }],
"fields": [
{
"id": "b9eaf0d0-...",
"signatoryId": "0e8f0597-...",
"type": "signature",
"pageIndex": 0,
"positionX": 40.5,
"positionY": 26.9598
},
{
"id": "16f6ac2a-...",
"signatoryId": "0e8f0597-...",
"type": "initials",
"pageIndex": 0,
"positionX": 69.2466,
"positionY": 87.8171
}
]
}
],
"signatories": [
{
"id": "0e8f0597-...",
"name": "Alex Smith",
"email": "alex.smith@acme.com",
"type": "member",
"title": null,
"order": 0
},
{
"id": "c94d5d06-...",
"name": "Jane Doe",
"email": "jane.doe@acme.com",
"type": "member",
"title": "CEO",
"order": 1
},
{
"id": "2f768c84-...",
"name": "",
"email": "john.doe@external.com",
"type": "guest",
"title": null,
"order": null
}
]
}
}
```
> **Notes**
>
> * `downloadUrl` is a pre-signed URL valid **5 minutes**, see `expiresAt`. Download the file straight away and do not store the URL. Re-call the endpoint to get a fresh one.
> * `isMain` distinguishes the main document from its annexes, `files[].order` gives the order of the bundle.
> * **Signing order**: `firstSigningParty` says which side signs first (`null` when the signature is not ordered). Only internal signatories (`member`) are ranked among themselves, with `signatories[].order`. Guests are never ranked: their `order` is always `null`. In the example above: Alex, then Jane, then the guest. See 3.3.
> * `fields` gives the position of the signature zones placed in Tomorro. Use them, or let the third-party tool place its own. Coordinates are explained in 3.3.
> * Returns `409` when there is no signature in progress on the contract: never sent for signature, canceled, or already signed. In the last case the executed PDFs are on `GET /v2/contracts/{id}/signed-files`.
>
> *View enum details in 3.3*
**4. Step 3 - Retrieve more contract information (optional)**
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}
```
Only needed if the third-party tool requires data that is not in the `signature.requested` payload or in `signing-files` (contract type, smart fields, dates, counterparty details).
**5. Step 4 - Work in the third-party tool**
Out of scope. Create the envelope, send it, wait for the signature.
While the signature is running in the third-party tool, **Tomorro sends nothing to the signatories**: no request email, no reminder, no magic link. The contract simply sits in `Signing`.
**Keep the link to the contract.** Store `contractId` on the envelope (external id, metadata or custom field, depending on your tool), or keep your own `contractId` ↔ envelope table. Flows 2 and 4 need it to know which contract to call back, and Flow 3 needs it to find the envelope to kill.
### Flow 2 - \[Third-Party → Tomorro] Signature is completed
**1. Step 0 - Trigger on the third-party tool**
Out of scope.
> [!NOTE]
>
> Steps 1 to 3 push the signed PDFs back to Tomorro. Each PDF (main document and each annex) goes through steps 1 and 2. Step 3 then runs **once**, with every file.
**2. Step 1 - Get an upload URL, one per PDF**
```http theme={null}
POST https://api.tomorro.com/v2/files/upload-url
x-api-key:
Content-Type: application/json
{ "contentType": "application/pdf" }
```
Response `201`:
```json theme={null}
{
"data": {
"url": "https://s3.eu-west-3.amazonaws.com/...",
"key": "signedFiles/392ef54d-.../files/59fb54e9-..."
}
}
```
> **Notes**
>
> * Read the values under `data`: `data.url` and `data.key`.
> * `data.url` is valid **5 minutes**. Do step 2 straight away.
> * Keep `data.key` for step 3. Once the file is uploaded, the key stays usable until a successful step 3 consumes it.
**3. Step 2 - Upload the PDF to data.url**
This call goes to the storage URL, **not** to the Tomorro API.
```http theme={null}
PUT
Content-Type: application/pdf
```
> **Notes**
>
> * **The body is the raw file bytes**: not `multipart/form-data`, not base64, not JSON.
> * n8n: HTTP Request node, body content type **n8n Binary File**.
> * Make: HTTP module, send the file data as the raw request body.
> * Code: `curl -X PUT --data-binary @signed.pdf -H "Content-Type: application/pdf" ""`
> * **No `x-api-key` and no `Authorization` header.** The URL carries its own signature. An extra auth header gets the upload refused.
> * `Content-Type` must be `application/pdf`, the same value as in step 1.
> * **Send the real PDF.** Storage accepts any bytes here, but Tomorro reads the uploaded file in step 3 and refuses anything that is not a PDF (`400 SignedFileNotPdf`), whatever `Content-Type` was declared.
> * Use `data.url` exactly as returned, query string included. Do not decode or re-encode it.
> * Expected response: `200`, empty body.
**4. Step 3 - Push the contract to Tomorro**
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{contractId}/signed-version
x-api-key:
Content-Type: application/json
{
"files": [
{
"key": "signedFiles/392ef54d-.../files/59fb54e9-...",
"name": "NDA - Acme (signed).pdf",
"contentType": "application/pdf"
},
{
"key": "signedFiles/392ef54d-.../files/ba093c1c-...",
"name": "Annex 1 (signed).pdf",
"contentType": "application/pdf"
}
],
"signatureDate": "2026-09-07T10:15:00.000Z"
}
```
Response: `204 No Content`, **empty body**. Do not try to parse JSON on success.
> **Notes**
>
> | Field | Rule |
> | - | - |
> | `files` | 1 to 20 PDFs, **main document first**, then its annexes. The order is kept. |
> | `files[].key` | `data.key` from step 1, once step 2 has succeeded. |
> | `files[].name` | File name shown in Tomorro. Must end with `.pdf`. |
> | `files[].contentType` | Always `application/pdf`. Any other type is refused. |
> | `signatureDate` | When the **last signatory signed** in your tool, not when you make the call. ISO 8601 with a time zone: `2026-09-07T10:15:00.000Z` or `2026-09-07T12:15:00+02:00`. Cannot be in the future. |
>
> The body is strict: any other field is refused with a `400`. That includes `contractId`, which already goes in the URL.
>
> What Tomorro does on receipt:
>
> * attaches the PDFs as the contract's signed files, and marks the document that was sent for signature as signed
> * marks every signatory as signed on `signatureDate`
> * moves the contract to `signed` and stamps its signature date. If the contract type sets start date = signature date, the start date takes the same value
> * runs the usual post-signature automations: notification to the contract members, `contract.signed` webhook, syncs…
>
> **Errors on step 3**
>
> | Code | `errorId` | Meaning | What to do |
> | - | - | - | - |
> | `400` | none, see `details` | Invalid body: not a PDF, name without `.pdf`, `signatureDate` missing, invalid or in the future, 0 or more than 20 files, unknown field. | Fix the payload. Do not retry as is. |
> | `400` | `FileIncorrectFileKey` | A `key` is unknown, belongs to another workspace, was already consumed, or no file was uploaded under it (step 2 skipped or sent to another URL). | Redo steps 1 and 2 for that file, then step 3. |
> | `400` | `SignedFileNotPdf` | A file uploaded in step 2 is not a real PDF, even though it was declared as `application/pdf` (e.g. a Word document). | Upload the actual signed PDF (steps 1 and 2), then step 3. |
> | `409` | `ContractAlreadySigned` | The contract is already signed, typically by your own previous call. | Treat as success. |
> | `409` | `ContractCanceled` | The contract was canceled in Tomorro. | Stop, there is nothing to sign. |
> | `409` | `SignatureStartedInAnotherProvider` | A signature is in progress on the contract with Tomorro or DocuSign, not with your tool. | Stop. A Tomorro user must cancel that signature first. |
>
> On a `409`, `error.message` only says `Conflict`: **branch on `error.errorId`**.
>
> A refused call (`400` or `409`) changes nothing on the contract, and the keys stay usable.
>
> **Retries**
>
> If step 3 times out or answers `5xx`, you cannot tell whether it went through. Call `GET /v2/contracts/{contractId}`:
>
> * `data.status` is `signed`: done.
> * otherwise: send step 3 again with the same body. The keys are still valid, since only a `204` consumes them.
>
> A `409 ContractAlreadySigned` on a retry means the first call worked.
>
> **Also usable outside this flow**
>
> The same endpoint accepts a contract in `draft` or `negotiating` that was never sent for signature, for instance to import a contract signed elsewhere. The first PDF then becomes a new version of the document, marked signed. Everything else is identical.
### Flow 3 - \[Tomorro → Third-Party] Signature canceled in Tomorro
**1. Step 0 - A user cancels the signature on Tomorro**

**2. Step 1 - Receive the signature.canceled webhook**
Same envelope and same verification as 3.2.1 and 3.2.2, with `eventType` = `signature.canceled`.
`data` carries one field the `signature.requested` payload does not: `cancelReason`, the reason typed by whoever canceled in Tomorro. It is a string, or `null` when no reason was given. Carry it into the cancellation on your side rather than voiding the envelope with no explanation.
```json theme={null}
{
"eventId": "757b6308-...",
"webhookId": "eeaa7b6d-...",
"createdAt": "2026-09-21T07:38:11.027Z",
"eventType": "signature.canceled",
"data": {
"organizationId": "...",
"contractId": "...",
"signatureId": "...",
"contractName": "NDA - Acme",
"counterparty": { "id": "...", "name": "Acme" },
"signatories": [ "... same shape as signature.requested ..." ],
"contractMembers": [ "... same shape as signature.requested ..." ],
"signatureNote": null,
"cancelReason": "Wrong counterparty entity"
}
}
```
**3. Step 2 - Cancel in the third-party tool**
Out of scope. Kill the envelope on the third-party side so the signatories stop receiving its reminders.
***No call back to Tomorro. The flow ends at step 2.***
*The signature is already canceled on the Tomorro side, the contract is already back to `draft` or `negotiating`. Do **not** call `cancel-signatures` here. It would be rejected with a `400`, since the contract is no longer in `signing`.*
**Expect the echo from your tool.** Killing the envelope usually makes your tool fire its own "canceled" event, which starts your Flow 4. There, `cancel-signatures` answers `400` because the contract is no longer in `signing`: this is expected, treat it as success. To skip the extra call, ignore that event in Flow 4 when the cancel came from Tomorro.
### Flow 4 - \[Third-Party → Tomorro] Signature canceled in the third-party tool
**1. Step 0 - Trigger on the third-party tool**
The signature will not complete: refused, canceled or expired in the third-party tool.
Out of scope.
**2. Step 1 - Cancel the signature in Tomorro**
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{contractId}/cancel-signatures
x-api-key:
{ "cancelReason": "Refused by the signatory in Yousign" }
```
`cancelReason` is optional.
> **Notes**
>
> Tomorro runs the same flow as the cancel button in the app: the contract leaves `Signing`, goes back to `draft` or `negotiating`, and the contract participants are notified.
>
> Response: `204 No Content`, **empty body**. Rejected with a `400` if the contract is not in `signing` (never sent for signature, already signed or canceled).
>
> **Retry**: if the call times out or answers `5xx`, send it again. A `400` on that retry means the first call went through: treat it as success.
**3. Step 2 - Ignore the echo webhook**
This cancellation also fires `signature.canceled` to your endpoint, exactly like a cancel made in Tomorro (Flow 3). The envelope is already dead on your side: verify the signature, answer 2xx, and do nothing else.
### Flow 5 - \[Third-Party → Tomorro] Add attachments (optional)
Adds a file to the contract's attachments, next to its signed document. Typically the **signature certificate** when your tool does not put it in the signed bundle, or any supporting document it produces (audit trail, evidence file...).
* **When**: usually right after Flow 2 has answered `204`. The endpoint works on a contract in any status and **never changes the status**.
* **One file per call.** Unlike `signed-version`, which takes every PDF in one call, each attachment runs through steps 1, 2 and 3 on its own.
* **Not only PDFs**: PDF, Word, Excel and more. See *Accepted file types* in 3.3.
> [!NOTE]
>
> Steps 1 and 2 are the same upload as in Flow 2, for any accepted file type. Each attachment goes through steps 1 to 3 on its own. The **same `contentType`** is sent in all three steps, and the file `name` must end with the **matching extension** (`.pdf` for `application/pdf`, `.xlsx` for Excel...).
**1. Step 1 - Get an upload URL, one per file**
```http theme={null}
POST https://api.tomorro.com/v2/files/upload-url
x-api-key:
Content-Type: application/json
{ "contentType": "application/pdf" }
```
Replace `application/pdf` with the content type of your file (see *Accepted file types*).
Response `201`:
```json theme={null}
{
"data": {
"url": "https://s3.eu-west-3.amazonaws.com/...",
"key": "signedFiles/392ef54d-.../files/ba093c1c-..."
}
}
```
> **Notes**
>
> * Read the values under `data`: `data.url` and `data.key`.
> * n8n: in the following nodes, reference them by the node name, e.g. `{{ $('Get upload URL').item.json.data.key }}`.
> * Make: enable **Parse response** on the HTTP module so `data.url` and `data.key` can be mapped in the next modules.
> * `data.url` is valid **5 minutes**. Do step 2 straight away.
> * Keep `data.key` for step 3. Once the file is uploaded, the key stays usable until a successful step 3 consumes it. Pass it **unchanged** (no trimming, no leading `/`, not the URL): one key = one file = one attachment.
> * A content type that is not accepted is already refused here with `400 FileContentTypeNotAllowed`.
**2. Step 2 - Upload the file to data.url**
This call goes to the storage URL, **not** to the Tomorro API.
```http theme={null}
PUT
Content-Type: application/pdf
```
> **Notes**
>
> * **The body is the raw file bytes**: not `multipart/form-data`, not base64, not JSON.
> * n8n: HTTP Request node, method `PUT`, URL `data.url`, body content type **n8n Binary File**, pointing at the binary property that holds the file.
> * Make: HTTP module, method `PUT`, send the file data as the raw request body.
> * Code: `curl -X PUT --data-binary @certificate.pdf -H "Content-Type: application/pdf" ""`, or in Node.js `await fetch(url, { method: 'PUT', headers: { 'Content-Type': contentType }, body: fileBuffer })`.
> * **No `x-api-key` and no `Authorization` header.** The URL carries its own signature. An extra auth header gets the upload refused. In n8n and Make, make sure this node does not reuse the Tomorro credential (authentication: none).
> * `Content-Type` must be the same value as in step 1.
> * Use `data.url` exactly as returned, query string included. Do not decode or re-encode it.
> * Expected response: `200`, empty body.
**3. Step 3 - Attach the file to the contract**
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{contractId}/attachments
x-api-key:
Content-Type: application/json
{
"key": "signedFiles/392ef54d-.../files/ba093c1c-...",
"name": "Signature certificate.pdf",
"contentType": "application/pdf",
"isSharedWithGuest": true
}
```
Response: `204 No Content`, **empty body**. Do not try to parse JSON on success.
> **Notes**
>
> | Field | Rule |
> | - | - |
> | `key` | **Required.** `data.key` from step 1, once step 2 has succeeded. |
> | `name` | **Required.** File name shown in Tomorro. Must end with the extension matching `contentType`. |
> | `contentType` | **Required.** The same value as in steps 1 and 2. See *Accepted file types*. |
> | `isSharedWithGuest` | Optional boolean, **default `false`**. `true` = **external**: the file is also shared with the contract guests, so the counterparty sees it. `false` = **internal**: only the members of your organization see it. |
>
> The body is strict and **flat**: the four fields sit at the root, with no `file` wrapper. Any other field is refused with a `400`, including `contractId`, which already goes in the URL.
>
> See *Accepted file types* below in 3.3.
>
> **Errors on step 3**
>
> | Code | `errorId` | Meaning | What to do |
> | - | - | - | - |
> | `400` | none, see `details` | Invalid body: a required field is missing or empty, an unknown field is sent (`file`, `contractId`...), `isSharedWithGuest` is not a boolean. | Fix the payload. Do not retry as is. |
> | `400` | `FileContentTypeNotAllowed` | `contentType` is not in the accepted list. | Use a supported format (3.3). |
> | `400` | `FileExtensionMismatchContentType` | The extension of `name` does not match `contentType`, e.g. `report.xlsx` sent as `application/pdf`. | Fix `name` or `contentType`. |
> | `400` | `FileIncorrectFileKey` | `key` is not the value returned by step 1: altered, empty, or obtained with an API key of another workspace. | Pass `data.key` exactly as returned. If in doubt, redo steps 1 and 2. |
> | `403` | none | The contract does not exist or is not visible to the automation account, the account is not allowed to add files, or attachments are not included in your plan. Indistinguishable, on purpose. | Do not retry. Check 1.1. |
>
> A refused call changes nothing on the contract.
>
> **Retries**
>
> A key is consumed by a successful step 3. If step 3 times out or answers `5xx`, retry **once** with the same body: if the first call actually went through, the retry is refused and the file is already on the contract. Do not start over from step 1 in that case, or the file ends up attached twice.
>
> **Also usable outside this flow**
>
> The endpoint is not tied to the signature: any integration can use it to add a supporting document to any contract, in any status.
## 3. Technical references
Reference material: conventions, webhook mechanics, enums and errors. Read it once, then come back to it while implementing the flows above.
### 3.1 - API conventions
* **REST only.** The whole integration runs on the public REST API.
* **Base URL**: `https://api.tomorro.com/v2`
* **Authentication**: `x-api-key: ` on every call. A missing or unknown key returns 401.
* **Versioning**: optional `tomorro-version` header, defaults to the latest version.
* **Rate limit**: 40 calls per minute, well above what this integration needs.
* **Sandbox**: available on request. It is an empty sandbox, not a copy of your production workspace.
* **Content type**: `application/json`
* **Response envelope**: every **successful** JSON response is wrapped in a `data` object (errors come as `{ error, meta }`, see 3.4). Read `data.url`, `data.files` and so on. A `204` response has no body at all.
### 3.2 - Receiving webhooks
Full documentation: [Technical documentation - Tomorro API](/webhooks/technical-documentation)
#### 3.2.1 - Payload
Tomorro sends a `POST` to the configured URL. Both events use the same envelope and the same `data` shape, the business payload sits under `data`.
These webhooks are organization-based, meaning every contract sent for signature through the third-party option will fire.
A delivery triggered by the **test buttons** in the admin console carries one extra top-level field, `"test": true`, absent from every genuine event. See 3.2.4.
```json theme={null}
{
"eventId": "9aa01b69-...",
"webhookId": "eeaa7b6d-...",
"createdAt": "2026-09-04T14:32:10.000Z",
"eventType": "signature.requested",
"data": {
"organizationId": "...",
"contractId": "...",
"signatureId": "...",
"contractName": "NDA - Acme",
"counterparty": { "id": "...", "name": "Acme" },
"signatories": [
{ "id": "...", "name": "Jane Doe", "email": "jane.doe@acme.com", "order": 0, "type": "member", "title": "CEO" },
{ "id": "...", "name": "John Doe", "email": "john.doe@external.com", "order": null, "type": "guest", "title": null }
],
"contractMembers": [ { "id": "...", "email": "..." } ],
"signatureNote": "...",
"cancelReason": "..." // only for signature.canceled
}
}
```
#### 3.2.2 - Signature verification
Header `Leeway-Signature` (also sent as `Leeway_Signature`):
```text theme={null}
Leeway-Signature: t=1788860270398,sha256=3f1c9d02a7...
```
Verification steps:
1. Split the header on `,` to get `t` (timestamp in milliseconds) and `sha256`.
2. Recompute `HMAC-SHA256(secret, ".")` in hexadecimal. **Use the raw body**, not a re-serialized JSON object, otherwise the hash will not match.
3. Compare in constant time with the `sha256` value.
4. Reject the call if the signature does not match, and reject a `t` that is too old to protect against replay (5 minutes is a reasonable window).
#### 3.2.3 - Acknowledgement and retries
* **Timeout 3000 ms.** The endpoint must answer 2xx in under 3 seconds. Acknowledge first, do the work asynchronously.
* Any non-2xx answer or timeout triggers a retry: **10 attempts in total** (first delivery + 9 retries), 5 minutes apart, about 45 minutes. The count is per event, but reaching it disables the whole webhook.
* **After the 10th failure the webhook is automatically disabled** (`enabled = false`). It has to be re-enabled in the admin console, and the "Other signature tool" option disappears from the UI while it is disabled. A contract that was already switched to the provider keeps it listed, but cannot be sent for signature until the endpoint is active again.
* **A `signature.requested` that fails all 10 attempts is rolled back in Tomorro**: the signature is canceled by Tomorro, the contract goes back to `negotiating` and the sender receives the usual "signature canceled" email asking to send again. No `signature.canceled` reaches you, since the webhook is disabled by then. If your tool did create the envelope anyway (answer later than 3 s), you can still push the signed PDFs on `POST /v2/contracts/{id}/signed-version`, which accepts a contract in `negotiating`.
* **Events fired while the webhook is disabled are dropped**, not queued and not replayed on re-enable. A signature canceled in Tomorro during that window never reaches your endpoint: after re-enabling, reconcile the contracts you had in `signing`.
* **Idempotency**: a retry resends the same `eventId`. Deduplicate on `eventId`, an event can legitimately arrive several times.
#### 3.2.4 - Test fires from the admin console
The **Send a test** buttons in the admin console fire a **real, signed delivery** at your URL: same envelope, same `Leeway-Signature` header, same 3 s timeout. The body is synthetic: its ids name **no real contract**, so every API call made with them is rejected.
A test fire is recognizable by one extra top-level field, which never appears on a genuine event:
```json theme={null}
{
"eventId": "7f3c1a02-9d4e-4b16-8c5a-21e0f6b8d734",
"webhookId": "",
"createdAt": "2026-09-21T09:19:12.921Z",
"eventType": "signature.requested",
"test": true,
"data": {
"organizationId": "",
"contractId": "c41b7e58-2a6d-4f93-b0e7-5d8c39a17b62",
"signatureId": "5a0e9c73-1d4b-4f28-9b6e-83c2f7d41a06",
"contractName": "Test contract",
"counterparty": { "id": "9e2d4f81-6b35-47ac-a91f-0c7b52e8d413", "name": "Test counterparty" },
"signatories": [
{
"id": "2b54acdd-5f44-4245-845e-4a3a1d346724",
"name": "John Doe",
"email": "john.doe@external.com",
"order": null,
"type": "guest",
"title": "Legal Counsel"
},
{
"id": "cd9e52bf-34e1-45cc-ba00-1028b2244a3f",
"name": "Jane Doe",
"email": "jane.doe@internal.com",
"order": null,
"type": "member",
"title": "CEO"
}
],
"contractMembers": [
{ "id": "3d8a61f4-5c07-4e2b-9a34-7f1b0d6e59c8", "email": "jane.doe@internal.com" },
{ "id": "bd9e52bf-39e1-45cc-ba00-1024b2244a3f", "email": "richard.roe@internal.com" }
],
"signatureNote": "Test signature note"
}
}
```
**Branch on `test`: verify the signature, answer 2xx, and stop there.**
The `signature.canceled` test is the same body with `"eventType": "signature.canceled"` and `data.cancelReason`. Tests are not retried and do **not** count towards the 10 failures that disable the webhook (3.2.3).
### 3.3 - Enum and field reference
| Field | Values | Note |
| - | - | - |
| `eventType` | `signature.requested` , `signature.canceled` | Only these two for this integration. |
| `data.status` on `GET /v2/contracts/{id}` | `draft` , `negotiating` , `signing` , `signed` , `canceled` | `signing` while the signature runs in your tool. A canceled signature sends the contract back to `draft` or `negotiating`, not to `canceled`. |
| `signatories[].type` | `member` , `guest` | `member` = internal party, `guest` = external party. |
| `signatories[].order` | integer or `null` | Rank among the internal signatories (`member`), **starting at 0**. Always `null` on guests, who are never ranked among themselves, and on everyone when the signature is not ordered. Which side signs first is carried by `firstSigningParty`. |
| `signatories[].name` | string | **Identify a signatory by `email`, never by `name`.** `name` can be `""` when a guest was invited by email and has not named itself yet, on the webhook and on `signing-files` alike. |
| `signatories[].title` | string or `null` | The signatory's job title, `null` when not filled in. |
| `signatureNote` | string or `null` | The note typed by the sender. `null` when no note was written. |
| `signatureId` | string (UUID) | The Tomorro signature the event is about. Same value on the `signature.requested` and the `signature.canceled` of one ceremony; a new one after a cancel and re-send. |
| `cancelReason` | string or `null` | **Only on `signature.canceled`.** The reason typed by whoever canceled, `null` when none was given. |
| `firstSigningParty` | `member` , `guest` , `null` | Which side signs first. `member` = internal party, `guest` = external party. `null` = when the signature is not ordered. |
| `files[].fields[].type` | `signature` , `initials` | **Different axis from `signatories[].type`.** Both are named `type`, one says who, the other says what kind of mark. |
| `files[].pages[]` | `{ width, height }` | Page dimensions in points, one entry per page in page order, to convert the percentages into absolute coordinates. Floats, not integers (e.g. `595.303937007874`). |
| `files[].fields[].positionX` / `positionY` | float | Percentage (0 to 100) of the page width and height. It locates the **top-left corner of the field**, measured from the top-left corner of the page. In PDF points: `x = positionX / 100 × pages[pageIndex].width`, same for `y` with `height`. The origin is the **top-left** corner and `y` grows downwards. If your tool measures from the **bottom-left** (native PDF coordinates), convert: `y = height − y`, minus the field height if your tool positions a field by its bottom-left corner. No field width or height is sent: use the default field size of your tool. |
| `files[].fields[].pageIndex` | integer | **0-based**, `0` is the first page. |
**Accepted file types**
For `files/upload-url` and `attachments` (Flow 5). The `name` must end with the extension on the same row, and the same `contentType` is sent in the three steps. `signed-version` (Flow 2) only accepts PDF.
| File | `name` ends with | `contentType` |
| - | - | - |
| **PDF** | `.pdf` | `application/pdf` |
| **Word** | `.docx` | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` |
| **Excel** | `.xlsx` | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` |
> **Other accepted types**
>
> | File | `name` ends with | `contentType` |
> | - | - | - |
> | Word (legacy) | `.doc`, `.rtf` | `application/msword` |
> | Excel (legacy) | `.xls` | `application/vnd.ms-excel` |
> | Excel with macros | `.xlsm` | `application/vnd.ms-excel.sheet.macroenabled.12` |
> | PowerPoint | `.pptx` | `application/vnd.openxmlformats-officedocument.presentationml.presentation` |
> | PowerPoint (legacy) | `.ppt` | `application/vnd.ms-powerpoint` |
> | Outlook email | `.msg` | `application/vnd.ms-outlook` |
> | OpenDocument text | `.odt` | `application/vnd.oasis.opendocument.text` |
> | OpenDocument spreadsheet | `.ods` | `application/vnd.oasis.opendocument.spreadsheet` |
> | OpenDocument presentation | `.odp` | `application/vnd.oasis.opendocument.presentation` |
> | OpenDocument drawing | `.odg` | `application/vnd.oasis.opendocument.graphics` |
> | CSV | `.csv` | `text/csv` |
> | Text | `.txt` | `text/plain` |
> | JPEG image | `.jpg`, `.jpeg` | `image/jpeg` |
> | PNG image | `.png` | `image/png` |
> | HEIC / HEIF image | `.heic` / `.heif` | `image/heic` / `image/heif` |
> | TIFF image | `.tiff`, `.tif` | `image/tiff` |
> | WebP image | `.webp` | `image/webp` |
### 3.4 - Error handling
Every endpoint follows the standard error format of the REST API, no specific contract for this integration.
**Error envelope**
```json theme={null}
{
"error": {
"statusCode": "409",
"errorId": "optional, present on some domain errors",
"message": "Human readable message",
"details": [
{ "field": "files.0.contentType", "message": "..." }
]
},
"meta": {
"timestamp": "2026-09-08T10:09:22.634Z",
"requestId": "req_Ab12Cd34"
}
}
```
* `error.statusCode` is a **string**, not a number.
* `error.details` is only present on validation errors, one entry per offending field.
* `meta.requestId` is the value to quote to Tomorro support when reporting a failing call. Log it. It is also sent in the `x-request-id` response header.
**Status codes (summary)**
Details per endpoint are in Flows 2, 4 and 5.
| Code | Meaning | Retry? |
| - | - | - |
| `400` | Invalid body (payloads are strict, unknown fields are refused), or contract not in `signing` on `cancel-signatures`. | No |
| `401` | Missing or unknown `x-api-key`. | No |
| `403` | No permission, or contract missing or not visible to the automation account. Indistinguishable, on purpose. | No |
| `404` | Only on `GET /v2/contracts/{id}` (`ContractNotFound`). | No |
| `409` | State conflict. Branch on `error.errorId` (`ContractAlreadySigned` = success). On `signing-files`, no `errorId`: read `error.message`. | No |
| `429` | Rate limit exceeded (40 calls per minute). | Yes, with backoff |
| `500` / timeout | Internal error. | Yes, with backoff. Once only on `attachments` (Flow 5). |
### 3.5 - Tomorro REST API Collection
> **[Get the Tomorro OpenAPI collection](https://drive.google.com/drive/folders/1YXxdcyQ7cBKlkSu5FGwl0nZx5Vk5-t0U)**
>
> `tomorro-openapi.json`
# Non-Technical scoping
Source: https://alpha.developer.tomorro.com/guides/create-contracts-from-your-hris/non-technical-scoping
Create contracts from your HRIS: what the business owner decides, and the brief to hand to IT
**Goal:** a new employee is created in your HRIS (Lucca, Workday, Personio, HiBob, PayFit...), and their employment contract is ready in Tomorro: filled in with the employee's data and already sent for signature. Nobody types it twice, nobody forgets to send it.
**TL;DR:** one flow, plus an optional one, run by a no-code tool (Make, n8n) or a few lines of code.
* **Flow 1: New employee in the HRIS.** Tomorro creates the employment contract from your template, with the employee's data (name, date of birth, salary, job title, grade...). The employee is added as the external signatory, your internal signatory is already set on the template, and the contract goes straight to signature.
* **Flow 2 (optional): Contract signed.** The signed PDF and the signature date go back to the employee's record in the HRIS.
**For the business owner.** A few decisions, no technical skills needed. Fill in the brief below and send it to your IT team or integrator: with the [Technical recipes](/guides/create-contracts-from-your-hris/technical-recipes), it has everything they need.
## Brief for your IT team
Copy this brief, fill in the blanks, and send it to your IT team or integrator. **It is all they need from you.**
```markdown Brief for IT theme={null}
Subject: Create contracts from the HRIS x Tomorro — ready to build
Hi,
I have done the business part of the HRIS x Tomorro contract creation automation. Over to you:
everything you need to build it is below.
1. Scope
- HRIS: ______________________ (e.g. Lucca)
- Employees in scope: ______________________ (e.g. new permanent hires of the French entity)
- Tomorro contract type: ______________________ (e.g. Employment contract)
- Template(s): ______________________ (e.g. one per job: Sales, Engineering, Operations)
- Contract language(s): ______________________ (e.g. French, English for non-French speakers)
- Internal signatory (set on the template): ______________________ (e.g. the HR Director)
- Send the signed contract back to the HRIS (Flow 2): yes / no
2. Data mapping (HRIS field -> Tomorro field)
- ______________________ -> External signatory email (required)
- ______________________ -> ______________________
- ______________________ -> ______________________
- ______________________ -> ______________________
- ______________________ -> ______________________
- New HRIS field "Tomorro contract" -> filled by the integration
3. Timing
- Flow 1 - New employee in the HRIS: ______________________ (e.g. as soon as HR validates the employee record)
- Flow 2 - Contract signed: ______________________ (e.g. as soon as the employee has signed)
4. Done on my side
- [ ] Contract type, template(s) and smart fields ready in Tomorro, internal signatory set
- [ ] Approval before signature: none on this contract type / approval kept (the contract waits for it)
Your part (all in the technical recipes):
- Technical recipes: https://alpha.developer.tomorro.com/guides/create-contracts-from-your-hris/technical-recipes
- API key: Tomorro > Settings > Integrations > API key, from a dedicated superadmin account
Thanks!
```
## Help to complete the brief
### 1 - Scope
| Question | Example answer | Why it matters |
| - | - | - |
| Which employees get a contract from Tomorro? | New permanent hires of the French entity | Only these trigger the automation |
| When is an employee record ready? | When HR validates it in the HRIS | The contract is only as good as the data it starts from |
| One template or one per job? | One per job | The integration picks the template from the job title |
| In which language(s)? | French, and English for non-French speakers | The language comes with the template: one template per language |
| Who signs for the company? | The HR Director, set on the template | Nothing to send for the internal side |
| Send the signed contract back to the HRIS? | Yes, in the employee's documents | If not, Flow 2 is not needed |
### 2 - Data mapping
One line per employee field you want in the contract.
> [!NOTE]
>
> Example only: list your own HRIS fields and the Tomorro fields they should fill.
| HRIS field | Tomorro field | Rule |
| - | - | - |
| Last name | Last name | Copied as is |
| First name | First name | Copied as is |
| Date of birth | Date of birth (date) | Copied as is |
| Email | External signatory | Required: the employee receives the contract to sign |
| Salary | Gross annual salary (number) | Copied as is |
| Job title | Job title (list) | Same list of values in both tools. Also picks the template |
| Preferred language | - | Picks the template in that language |
| Grade | Grade (list) | Same list of values in both tools |
| Tomorro contract | - | New HRIS field, filled automatically |
### 3 - Timing
When should each automation run, and what starts it?
> [!NOTE]
>
> Example only: choose the timing that fits your process.
| Flow | What starts it | When it runs |
| - | - | - |
| Flow 1 - New employee in the HRIS | Every employee record validated by HR | As soon as the record is validated in the HRIS |
| Flow 2 - Contract signed (optional) | Every employment contract signed in Tomorro | As soon as the employee has signed |
### 4 - Checklist
* [ ] Scope (1), mapping (2) and timing (3) validated
* [ ] Contract type and template(s) ready in Tomorro, one per language needed: one smart field per mapped data (**Libraries > Dynamic components > Smart fields**), the same list values as the HRIS, the internal signatory set and an empty signature zone for the employee
* [ ] Approval before signature decided for this contract type: none, or kept (the contract then waits for it before going to signature)
* [ ] An IT contact or integrator to build the flows, and the brief sent to them
# Technical recipes
Source: https://alpha.developer.tomorro.com/guides/create-contracts-from-your-hris/technical-recipes
Create contracts from your HRIS: prerequisites, the flows and the API reference for your IT team
Everything runs on the Tomorro REST API: one counterparty call, one contract call and one signature call per new employee. How you read and write the HRIS depends on your HRIS.
```mermaid theme={null}
sequenceDiagram
autonumber
actor U as HR user
participant H as HRIS
employee records
participant I as Integration
customer middleware
participant T as Tomorro
actor E as Employee
Note over U,E: Prerequisites: "automation" superadmin account, API key, contract type + template (smart fields, internal signatory, empty employee signature zone), "Tomorro contract" field in the HRIS
rect rgb(232, 244, 253)
Note over U,E: Flow 1: New employee in the HRIS (HRIS -> Tomorro -> HRIS)
U->>H: Creates and validates the employee record
H-->>I: New employee (event, webhook or polling)
I->>T: POST /v2/counterparties
name = first name + last name
T-->>I: 201 data.id
I->>T: POST /v2/contracts
contractTypeId + templateId + counterparty.id
fields + external signatory email
T-->>I: 201 data.id + contractUrl
I->>T: POST /v2/contracts/{id}/request-signatures
T-->>I: 200 data.status = sent
T->>E: Signature request email
I->>H: Write the contract id and contractUrl into "Tomorro contract"
end
rect rgb(245, 229, 164)
Note over U,E: Flow 2 (optional): Contract signed (Tomorro -> HRIS)
E->>T: Signs
Note over T: Internal signatory signs (order set on the template)
T->>I: POST webhook contractSigned
I-->>T: 200 OK
I->>T: GET /v2/contracts/{id}/signed-files
T-->>I: downloadUrl (valid 30 s)
I->>H: Upload the signed PDF + signature date to the employee record
end
```
## 1. Technical prerequisites
**1. 1.1 - Create Tomorro superadmin account**
An existing admin creates a dedicated **superadmin** account from **Settings > Members** (e.g. `automation@customer.com`), not a personal account.
* The API key is bound to a **member**, not to the organization. Every write performed through the API is attributed to that member and inherits its permissions.
* Consequence: this account is the author of every employment contract created by the integration. A named account such as "HRIS automation" keeps the contract timeline readable.
* Recommended: no personal mailbox behind it, and exclude it from Tomorro notification settings.
**2. 1.2 - API key**
Generate the API key from that superadmin account. It does not expire. It is sent on every REST call in the `x-api-key` header: [Step 1: Get your API key](/quickstart)
**3. 1.3 - Contract type and template in Tomorro**
The template carries everything the integration does not send.
* **Contract type** (e.g. *Employment contract*): its **default signature tool is Tomorro**, so the employee receives the signature email from Tomorro.
* **Smart fields**: one per mapped HRIS field (**Libraries > Dynamic components > Smart fields > New**), placed in the template. Pick the type matching the HRIS: *Text* for names, *Date* for the date of birth, *Number* for the salary, *Single-choice selector* for the job title and the grade (options = the HRIS values, see 3.3).
* **Signatories on the template**: the **internal signatory** is set on the template (e.g. the HR Director), and the **external signatory** has a signature zone but no one assigned. The integration fills that empty slot with the employee's email.
* **One template per job** if the contract differs by job: keep a `job title → templateId` table in the integration.
* **One template per language**: the contract takes the **language of its template** (`language` in `GET /v2/contract-types/{id}/templates`, e.g. `FR`, `EN`). To generate a contract in English, create the English template and pick it: the table becomes `job title + language → templateId`.
* Copy the ids next to your mapping ([Non-Technical scoping](/guides/create-contracts-from-your-hris/non-technical-scoping)): the contract type id from `GET /v2/contract-types`, the template ids from `GET /v2/contract-types/{id}/templates`, the smart field ids from `GET /v2/contract-types/{id}/creation-form` (or the last segment of the smart field URL).
| Mapping | API name |
| - | - |
| Contract type | `contractTypeId` |
| Template (per job and language) | `templateId` |
| Last name | `3f2b8c1e-...` (smart field id) |
| First name | `8a41d0f7-...` |
| Date of birth | `c9e07b52-...` |
| Email | `signatories.externalSignatoryEmailList` |
| Salary | `51d6a3e9-...` |
| Job title | `e7b94f20-...` (option label, see 3.3) |
| Grade | `0c5f8d16-...` (option label, see 3.3) |
| Tomorro contract | `id` and `contractUrl` (response) |
**4. 1.4 - Approval before signature**
If an approval workflow runs **before signature** on this contract type, the contract goes to approval first and is not sent to the employee straight away (Flow 1, step 3). Decide with the business owner: no approval on this contract type, or keep it and let the contract wait for it.
**5. 1.5 - HRIS: add the Tomorro contract field**
Add a **text** field `Tomorro contract` to the employee record, empty for every employee. The integration fills it with the contract id (and `contractUrl` if there is room); users must not edit it. It stops a second contract from being created for the same employee.
## 2. Flows
### Flow 1 - \[HRIS → Tomorro → HRIS] New employee in the HRIS
**1. Step 0 - Triggered by the HRIS**
Out of scope. An employee record is created and validated in the HRIS: HRIS event or webhook if it has one, otherwise poll the employees created since the last run. Skip it if `Tomorro contract` is already filled, or if a mapped field or the email is empty.
**2. Step 1 - Create the employee as a counterparty**
```http theme={null}
POST https://api.tomorro.com/v2/counterparties
x-api-key:
Content-Type: application/json
{ "name": "Jane Doe" }
```
Response `201`:
```json theme={null}
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Jane Doe",
"status": "active"
}
}
```
> **Notes**
>
> * Keep `data.id` for step 2.
> * **Why a separate call**: `POST /v2/contracts` also accepts `counterparty.name`, but it then **reuses** any counterparty with the same name (case and spaces ignored). Two employees named Jane Doe would share one counterparty. Creating it here, then passing its `id`, gives every employee their own.
**3. Step 2 - Create the employment contract**
```http theme={null}
POST https://api.tomorro.com/v2/contracts
x-api-key:
Content-Type: application/json
{
"contractTypeId": "6f1c2d3e-...",
"templateId": "9a8b7c6d-...",
"name": "Employment contract - Jane Doe",
"counterparty": { "id": "550e8400-e29b-41d4-a716-446655440000" },
"fields": [
{ "fieldId": "3f2b8c1e-...", "value": "Doe" },
{ "fieldId": "8a41d0f7-...", "value": "Jane" },
{ "fieldId": "c9e07b52-...", "value": "1994-03-12" },
{ "fieldId": "51d6a3e9-...", "value": "48000" },
{ "fieldId": "e7b94f20-...", "value": "Account Executive" },
{ "fieldId": "0c5f8d16-...", "value": "Level 2" }
],
"signatories": {
"externalSignatoryEmailList": ["jane.doe@example.com"]
}
}
```
Response `201`:
```json theme={null}
{
"data": {
"id": "7d12db6f-36a6-4dc5-b022-66471ff5xxxc",
"name": "Employment contract - Jane Doe",
"status": "draft",
"templateId": "9a8b7c6d-...",
"signatories": [ "... the internal signatory of the template, and jane.doe@example.com ..." ],
"contractUrl": "https://app.tomorro.com/acme/project/7d12db6f-36a6-4dc5-b022-66471ff5xxxc"
}
}
```
> **Notes**
>
> * **`templateId` is required in practice**: without it, the contract has no document and cannot be sent for signature. It also sets the contract **language**.
> * **`fields`**: one entry per smart field, `value` always a **string**. Dates as `YYYY-MM-DD`, numbers without spaces or currency (`"48000"`), picklists as the **exact option label** (see 3.3). Leave a field out rather than sending an empty string.
> * **Signatories**: only send the employee's email. It fills the **empty external signature zone** of the template; the internal signatory is already on the template. Do not add more signatories than the template has empty zones: the extra ones get no signature zone and step 3 is refused.
> * Required fields of the contract type's creation form that are missing: `400 IncompleteCreationForm`. Unknown `contractTypeId` or `templateId`: `404`.
> * Keep `data.id` and `data.contractUrl`.
**4. Step 3 - Send the contract for signature**
```http theme={null}
POST https://api.tomorro.com/v2/contracts/{contractId}/request-signatures
x-api-key:
Content-Type: application/json
{
"signatureNote": "Welcome aboard! Here is your employment contract.",
"isReminderEnabled": true
}
```
Response `200`:
```json theme={null}
{
"data": {
"id": "a1b2c3d4-...",
"status": "sent",
"signatories": [ "..." ],
"sentAt": "2026-10-02T09:15:00.000Z",
"contractUrl": "https://app.tomorro.com/acme/project/7d12db6f-36a6-4dc5-b022-66471ff5xxxc"
}
}
```
> **Notes**
>
> * Can be called **right after step 2**: the document and its signatories are ready when step 2 answers.
> * `documentId` is optional: without it, the last document of the contract is sent.
> * **Always check `data.status`**:
> * `sent`: done. The contract moves to `signing`, Tomorro emails the signatories in the signing order of the contract type and template.
> * `draft`: the signature was **not** sent. Usually an approval workflow before signature (1.4): the contract waits for it. Alert HR with `contractUrl`.
> * `signatureNote` is the message shown to the signatories. `isReminderEnabled` defaults to `true`.
>
> **Errors on step 3** (`400`, branch on `error.errorId`)
>
> | `errorId` | Meaning | What to do |
> | - | - | - |
> | `StartSignatureBlankSignatory` | A signature zone of the template has no one assigned (email missing in step 2, or a second empty zone). | Fix the template or the email, then retry. |
> | `StartSignatureMissingFields` | A signatory has no signature zone: more signatories sent in step 2 than empty zones on the template. | Fix the template, recreate the contract. |
> | `StartSignatureMissingSignatory` | No signatory at all on the contract. | Check the template signatories (1.3). |
> | `StartSignatureMissingDocumentSmartFieldsForParty` | A smart field of the document is still empty. | Send it in step 2, or fill it from `contractUrl`. |
> | `ContractNotReadyToPrepareSignature` | The contract cannot be sent as is: approval pending, pending suggestions, document already sent... | Open `contractUrl` and finish by hand. |
**5. Step 4 - Write the contract back to the HRIS**
Out of scope (depends on the HRIS). Write `data.id` (and `contractUrl`) from step 2 into `Tomorro contract`, straight away: until it is written, a new run would create a second contract for the same employee.
> [!TIP]
>
> **Rate limit**: no need to slow the calls down. If Tomorro answers `429`, retry with backoff.
### Flow 2 (optional) - \[Tomorro → HRIS] Contract signed
**1. Step 0 - Receive the contractSigned webhook**
Create the webhook **from the automation account** (1.1), trigger `contractSigned`: [Webhooks](/webhooks/overview). Webhooks only fire for contracts that concern the member who created them, and this account is the author of every contract of Flow 1.
Verify the `Leeway-Signature` header ([Technical documentation](/webhooks/technical-documentation)), answer `2xx` straight away, then keep `data.contract.id`, and `data.contract.signatureDate` for the HRIS.
**2. Step 1 - Find the employee**
Out of scope (depends on the HRIS). Look up the employee whose `Tomorro contract` field holds `data.contract.id` (Flow 1, step 4). No employee: the contract was not created by Flow 1, ignore the event.
**3. Step 2 - Get the signed PDF**
```http theme={null}
GET https://api.tomorro.com/v2/contracts/{contractId}/signed-files
x-api-key:
```
Response `200`:
```json theme={null}
{
"data": [
{
"id": "550e8400-...",
"filename": "Employment contract - Jane Doe - signed.pdf",
"contentType": "application/pdf",
"downloadUrl": "https://s3...",
"expiresAt": "2026-10-03T10:45:30.000Z",
"contractUrl": "https://app.tomorro.com/acme/project/7d12db6f-..."
}
]
}
```
`downloadUrl` is valid **30 seconds**: download the file straight away (no auth header), and re-call the endpoint for a fresh URL if needed.
**4. Step 3 - Upload to the employee record**
Out of scope (depends on the HRIS). Upload the PDF to the employee's documents and write the signature date. With Lucca, for instance, attach the uploaded file to the work contract by its `fileId`.
## 3. Technical references
Reference material: conventions, the endpoints, field values and error handling. Read it once, then come back to it while implementing the flows above.
### 3.1 - API conventions
* **REST only.** The whole integration runs on the public REST API.
* **Base URL**: `https://api.tomorro.com/v2`
* **Authentication**: `x-api-key: ` on every call. A missing or unknown key returns 401.
* **Versioning**: optional `tomorro-version` header, defaults to the latest version.
* **Rate limit**: a `429` means too many calls in a short time. Retry with backoff.
* **Sandbox**: available on request. It is an empty sandbox, not a copy of your production workspace.
* **Content type**: `application/json`
* **Response envelope**: every **successful** JSON response is wrapped in a `data` object (errors come as `{ error, meta }`, see 3.4). Read `data.id`, `data.status` and so on.
* **Full reference**: [Tomorro API](/api-reference/introduction)
### 3.2 - Endpoints
| Endpoint | Use in this integration |
| - | - |
| `GET /v2/contract-types` | Setup: the contract type id. |
| `GET /v2/contract-types/{id}/templates` | Setup: the template ids, their `language` and their smart fields. |
| `GET /v2/contract-types/{id}/creation-form` | Setup: the smart fields of the creation form, and which are `required`. |
| `POST /v2/counterparties` | Flow 1, step 1: the employee as a counterparty. Returns `201`. |
| `POST /v2/contracts` | Flow 1, step 2: the contract, its document, its fields and the employee as signatory. Returns `201`. |
| `POST /v2/contracts/{id}/request-signatures` | Flow 1, step 3: send for signature. Returns `200` with `data.status`. |
| `GET /v2/contracts?counterpartyId={id}` | Recovery: the contracts of an employee, to check a creation went through (3.4). |
| `GET /v2/contracts/{id}/signed-files` | Flow 2: the signed PDF. |
### 3.3 - Field values
`fields[].value` is always a string. Tomorro does not reformat it: send it in the format below.
| Smart field type | Send | Example |
| - | - | - |
| Text | The text | `"Doe"` |
| Date | `YYYY-MM-DD` | `"1994-03-12"` |
| Number | Digits, `.` for decimals, no spaces or currency | `"48000"` |
| Single-choice selector | The **exact option label**, case included | `"Account Executive"` |
| Multi-choice selector | Exact labels separated by `;` | `"French;English"` |
* **Picklists**: a label that matches no option is not refused, it is stored as an unknown value and shows empty in Tomorro. Align the labels on both sides first (job titles, grades), and translate HRIS codes into Tomorro labels in the integration when they differ.
* **Built-in contract fields** can be set the same way, by name instead of id: `startAt` (start date, `YYYY-MM-DD`), `endAt` (end date, for a fixed-term contract).
* `null` clears a field.
### 3.4 - Error handling
Every endpoint follows the standard error format of the REST API.
**Error envelope**
```json theme={null}
{
"error": {
"statusCode": "400",
"errorId": "optional, present on some domain errors",
"message": "Human readable message",
"details": [
{ "field": "fields.0.value", "message": "..." }
]
},
"meta": {
"timestamp": "2026-10-02T10:09:22.634Z",
"requestId": "req_Ab12Cd34"
}
}
```
* `error.statusCode` is a **string**, not a number.
* `meta.requestId` is the value to quote to Tomorro support when reporting a failing call. Log it.
| Code | Meaning | Retry? |
| - | - | - |
| `400` | Invalid body (unknown key, wrong type), `IncompleteCreationForm` on contract creation, or a signature blocker on `request-signatures` (see Flow 1, step 3). | No, fix the payload or the template |
| `401` | Missing or unknown `x-api-key`. | No |
| `403` | The API key's account is not allowed to create contracts of this type or to manage counterparties. | No, check 1.1 |
| `404` | Unknown contract type, template or contract. | No |
| `429` | Rate limit exceeded. | Yes, with backoff |
| `500` / timeout | Internal error. | Yes, with backoff. On a contract creation, check first that it was not created (`GET /v2/contracts?counterpartyId=` the id of step 1) to avoid a duplicate. |
### 3.5 - Edge cases
* **Link**: the `Tomorro contract` field in the HRIS. Never match on the employee's name.
* **Employee created twice in the HRIS, or flow re-run**: skipped as long as `Tomorro contract` is filled (Flow 1, step 0).
* **Rehire**: a returning employee already has a counterparty. Reuse its id (store it in the HRIS too) instead of creating a second one in step 1.
* **Missing email**: no external signatory, the contract cannot go to signature. Skip the employee in step 0 and alert HR.
* **Data changed in the HRIS after the contract was sent**: the integration does not update a contract in signature. Cancel it in Tomorro and clear `Tomorro contract` to generate a new one.
* **Triggers**: prefer an HRIS webhook or event on "employee validated", so Flow 1 runs as soon as the record is ready. Without one, poll the employees created since the last run, e.g. every 15 minutes.
# Guides
Source: https://alpha.developer.tomorro.com/guides/overview
Step-by-step guides for the integrations teams build with the Tomorro Public API
The Tomorro Public API lets you exchange data between Tomorro and the tools your teams already use: CRM, ERP, data warehouse, automation platform or internal app.
Each guide below covers one integration end to end:
* **For the business owner**: what the integration does, the few decisions to make, and a short checklist. No technical skills needed.
* **For IT**: the flows, the exact API calls, and a working example built with a no-code tool.
> **[Sync your counterparties](/guides/sync-your-counterparties/non-technical-scoping)**
>
> Keep the counterparties in your ERP and in Tomorro in sync, with no manual entry.
> **[Connect your own signature tool](/guides/connect-your-own-signature-tool/non-technical-scoping)**
>
> Send Tomorro contracts for signature in Yousign, Universign, Adobe Sign or any other tool, and get the signed PDF back.
> **[Create contracts from your HRIS](/guides/create-contracts-from-your-hris/non-technical-scoping)**
>
> A new employee in Lucca, Workday or Personio, and their employment contract is created and sent for signature.
> **[Back up your signed contracts](/guides/backup-your-signed-contracts/non-technical-scoping)**
>
> Every signed contract saved as a PDF in SharePoint, Google Drive, Box or S3, as soon as it is signed.
## Scope your own integration
Start from the end goal (*what should this automation achieve?*) and describe it as a simple **Trigger → Action → Data** flow:
* **Trigger**: the event that starts the automation, in Tomorro or in your tool.
* **Action**: what should happen as a result, in the other system.
* **Data**: the fields exchanged, and in which direction.
Need help with a use case not listed here? [Contact support](mailto:support@tomorro.com).
# Full cycle example
Source: https://alpha.developer.tomorro.com/guides/sync-your-counterparties/full-cycle-example
Sync your counterparties: a working example with a Notion database as the ERP and four Make scenarios
In this example, we build the whole integration with [Make](https://www.make.com) as the middleware, and a Notion database playing the role of the ERP, the counterparty base. Each of the four flows is one Make scenario: you can watch it run end to end in the video, then import the same scenarios from the [blueprints](#make-blueprints).
[Counterparty sync demo: Notion and Make](https://www.loom.com/embed/19dcfc6b90a142788f0bdd6073c7c0ca)
## Notion ERP with Make
[Make](https://www.make.com) is a no-code automation platform. The four Make scenarios below run every flow of this doc with a Notion database playing the ERP (*Mock Counterparties*: `Counterparty Name`, `SIRET Number`, `Industry`, `Address`, `City`, `ID tomorro`). Use them as a **head start** if you run Make, or as a reference implementation for n8n, custom code or another ERP: only the ERP modules change.
> **Notes**
>
> The scenarios are a working example, not a ready-made integration. They make choices you may want to change:
>
> * **Connections**: the Tomorro calls use an *API Key Auth* keychain (header `x-api-key`), the Notion calls a Notion connection invited on the counterparty database.
> * **Notion calls** go through *Make an API Call* (Notion API version `2022-06-28`), so the raw Notion JSON is parsed in code modules. With your ERP, replace these modules with its own connector.
> * **Webhooks**: Flows 1 and 2 start from a Notion webhook (*Watch Events*, event *Page: Properties updated*), within a minute or two of the change: Notion groups its events before sending them. No polling. The webhook receives events from the whole Notion workspace: pages the connection cannot read are skipped by an *Ignore* error handler, and a filter keeps only the counterparty database.
> * **Retries**: the Tomorro calls that write (create, update) retry automatically, 3 times, 1 minute apart. A bundle that still fails is kept as an incomplete execution, to review in Make.
> * **Sync state**: Flow 3 saves the last `updatedAt` it processed in a Make data store, and only reads what changed since.
> * **Pages**: Flow 0 reads up to 100 counterparties per run and can simply be re-run. Flow 3 reads the 50 latest updates per run; paginate with `pagination.next_cursor` above that.
> * **Industry**: labels are matched case-insensitively on the Tomorro options. A Notion value missing from Tomorro is left out and reported in the `warning` output of the code module.
### About the code below
The scenarios are mostly standard modules. The logic lives in a few short **JavaScript** snippets, run by Make *Code* modules: read the ERP row, translate the Industry label into its option id, and compare both sides before writing. They are already inside the [blueprints](#make-blueprints): you only need to copy them if you build the flows yourself.
| You use | How to reuse the snippets |
| - | - |
| **Make** | Nothing to copy: import the [blueprints](#make-blueprints). Open a Code module to adapt a field. |
| **n8n** | Paste them into a *Code* node. Replace `input` with the node input (`$json`) and return the result. |
| **Custom code** | Wrap each snippet in a function: `input` is its argument, the returned object is its result. |
| **Another ERP** | Only the reading part changes: replace the Notion property names (`p['Counterparty Name']`, `p['SIRET Number']`…) with your ERP fields. |
In every case, replace the smart field ids in `FIELDS` with yours (see [1.3](/guides/sync-your-counterparties/technical-recipes#1-3-counterparty-smart-fields-in-tomorro)).
### Flow 0 - \[Notion → Tomorro → Notion] Initialisation
Make scenario *Flow 0 - Initialisation*, scheduling **on demand** (Run once).
**1. Step 0 - List the counterparties not linked to Tomorro**
*Make modules 1, 3*
Query the database for rows with an empty `ID tomorro`, then iterate over `body.results`:
```http theme={null}
POST https://api.notion.com/v1/databases/{databaseId}/query
Notion-Version: 2022-06-28
{ "filter": { "property": "ID tomorro", "rich_text": { "is_empty": true } }, "page_size": 100 }
```
**2. Step 1 - Get the Industry option ids**
*Make module 2*
```http theme={null}
GET https://api.tomorro.com/v2/smart-fields/b25c969a-2a1c-4903-be86-10269aeb5e84/options?limit=50
x-api-key:
```
**3. Step 2.1 - Build the counterparty body**
*Make module 4*
Inputs: the row `id` and `properties`, and `data` of step 1. Skips rows already linked or without a name, and translates the Industry label into its option id.
```javascript theme={null}
const { pageId, properties, industryOptions } = input;
const p = properties || {};
const text = (prop) => ((prop && (prop.title || prop.rich_text)) || []).map((t) => t.plain_text).join('').trim();
const counterparty = {
tomorroId: text(p['ID tomorro']),
name: text(p['Counterparty Name']),
siret: text(p['SIRET Number']),
industry: (p['Industry'] && p['Industry'].select && p['Industry'].select.name) || '',
address: text(p['Address']),
city: text(p['City']),
};
// Already linked, or row created empty in the ERP: nothing to create yet
if (counterparty.tomorroId) return { skip: true, reason: 'already linked to Tomorro', pageId };
if (!counterparty.name) return { skip: true, reason: 'missing counterparty name', pageId };
// Tomorro smart field ids: Dynamic components > Smart fields, last segment of the URL
const FIELDS = {
siret: '9d6175af-86a5-497a-aa42-facfb2b8dafb',
industry: 'b25c969a-2a1c-4903-be86-10269aeb5e84',
address: '0abf8d86-02ab-4583-aea3-b730ad802c37',
city: 'a6690cab-baaa-4848-8866-a03d800049e3',
};
// Tomorro stores a picklist value as the option id, not its label: translate both ways
const optionIdByLabel = {};
const labelByOptionId = {};
for (const option of industryOptions || []) {
for (const translation of option.translations || []) {
const label = String(translation.value || '').trim();
if (!label) continue;
optionIdByLabel[label.toLowerCase()] = option.id;
if (!labelByOptionId[option.id]) labelByOptionId[option.id] = label;
}
}
const values = { ...counterparty, industry: counterparty.industry && optionIdByLabel[counterparty.industry.toLowerCase()] };
const warning = counterparty.industry && !values.industry ? `Industry "${counterparty.industry}" is not a Tomorro option` : null;
const fields = {};
for (const [key, fieldId] of Object.entries(FIELDS)) {
if (values[key]) fields[fieldId] = values[key];
}
return { skip: false, pageId, warning, body: JSON.stringify({ name: counterparty.name, fields }) };
```
**4. Step 2.2 - Create the counterparty in Tomorro**
*Make module 5*, filtered on `skip` not `true`, with a *Retry* error handler.
```http theme={null}
POST https://api.tomorro.com/v2/counterparties
x-api-key:
Content-Type: application/json
```
**5. Step 3 - Write ID tomorro back to Notion**
*Make module 6*
```http theme={null}
PATCH https://api.notion.com/v1/pages/{pageId}
Notion-Version: 2022-06-28
{ "properties": { "ID tomorro": { "rich_text": [ { "text": { "content": "" } } ] } } }
```
### Flow 1 - \[Notion → Tomorro → Notion] Counterparty created in the ERP
Make scenario *Flow 1 - Creation*, triggered by a Notion webhook.
**1. Step 0 - Notion webhook: a counterparty row is edited**
*Make modules 1, 2*
*Watch Events*, event **Page: Properties updated**, then read the raw row with `GET /v1/pages/{entity.id}`, filtered on the counterparty database.
A row added in Notion is often empty when it is created: the counterparty is ready once its fields are filled. So Flow 1 listens to property updates, and only creates a counterparty for a row with a name and no `ID tomorro`.
**2. Step 1 - Get the Industry option ids**
*Make module 3*
Same call as Flow 0 step 1.
**3. Step 2.1 - Build the counterparty body**
*Make module 4*
Same code as Flow 0 step 2.1, inputs `body.id` and `body.properties` of module 2.
**4. Step 2.2 - Create the counterparty in Tomorro**
*Make module 5*
Same call as Flow 0 step 2.2, filtered on `skip` not `true`, with a *Retry* error handler.
**5. Step 3 - Write ID tomorro back to Notion**
*Make module 6*
Same call as Flow 0 step 3.
### Flow 2 - \[Notion → Tomorro] Counterparty updated in the ERP
Make scenario *Flow 2 - Update from ERP*, triggered by a Notion webhook.
**1. Step 0 - Notion webhook: a counterparty row is edited, read it**
*Make modules 1, 2, 3*
*Watch Events*, event **Page: Properties updated**, `GET /v1/pages/{entity.id}` filtered on the counterparty database, then read the values. Rows without `ID tomorro` are left to Flow 1.
```javascript theme={null}
const { pageId, properties } = input;
const p = properties || {};
const text = (prop) => ((prop && (prop.title || prop.rich_text)) || []).map((t) => t.plain_text).join('').trim();
const counterparty = {
tomorroId: text(p['ID tomorro']),
name: text(p['Counterparty Name']),
siret: text(p['SIRET Number']),
industry: (p['Industry'] && p['Industry'].select && p['Industry'].select.name) || '',
address: text(p['Address']),
city: text(p['City']),
};
return { pageId, ...counterparty, skip: !counterparty.tomorroId || !counterparty.name };
```
**2. Step 1.1 - Read the current counterparty**
*Make module 4*, filtered on `skip` not `true` (row linked to Tomorro and named).
```http theme={null}
GET https://api.tomorro.com/v2/counterparties/{tomorroId}
x-api-key:
```
**3. Step 1.2 - Get the Industry option ids**
*Make module 5*
Same call as Flow 0 step 1.
**4. Step 2.1 - Compare Notion and Tomorro**
*Make module 6*
Inputs: the values of step 0, `data` of steps 1.1 and 1.2. Builds a `PATCH` body with the differences only. A Tomorro Industry that is not a valid option id counts as empty, so it is repaired.
```javascript theme={null}
const { name, siret, industry, address, city, counterparty, industryOptions } = input;
// Tomorro smart field ids: Dynamic components > Smart fields, last segment of the URL
const FIELDS = {
siret: '9d6175af-86a5-497a-aa42-facfb2b8dafb',
industry: 'b25c969a-2a1c-4903-be86-10269aeb5e84',
address: '0abf8d86-02ab-4583-aea3-b730ad802c37',
city: 'a6690cab-baaa-4848-8866-a03d800049e3',
};
// Tomorro stores a picklist value as the option id, not its label: translate both ways
const optionIdByLabel = {};
const labelByOptionId = {};
for (const option of industryOptions || []) {
for (const translation of option.translations || []) {
const label = String(translation.value || '').trim();
if (!label) continue;
optionIdByLabel[label.toLowerCase()] = option.id;
if (!labelByOptionId[option.id]) labelByOptionId[option.id] = label;
}
}
const current = counterparty || {};
const currentFields = current.fields || {};
const clean = (value) => (value === null || value === undefined ? '' : String(value).trim());
const tomorroValue = (fieldId) => {
const field = currentFields[fieldId];
return clean(field && typeof field === 'object' && 'value' in field ? field.value : field);
};
// Industry is compared on the option id; a Tomorro value that is not an option id is treated as empty
const erpIndustryId = clean(industry) ? optionIdByLabel[clean(industry).toLowerCase()] : '';
const tomorroIndustryId = labelByOptionId[tomorroValue(FIELDS.industry)] ? tomorroValue(FIELDS.industry) : '';
const erp = { siret: clean(siret), industry: erpIndustryId, address: clean(address), city: clean(city) };
const tomorro = {
siret: tomorroValue(FIELDS.siret),
industry: tomorroIndustryId,
address: tomorroValue(FIELDS.address),
city: tomorroValue(FIELDS.city),
};
const body = {};
if (clean(name) && clean(name) !== clean(current.name)) body.name = clean(name);
const fields = {};
for (const [key, fieldId] of Object.entries(FIELDS)) {
// ERP label missing from the Tomorro picklist: leave the Tomorro value alone
if (key === 'industry' && erpIndustryId === undefined) continue;
if (erp[key] !== tomorro[key]) fields[fieldId] = erp[key] || null;
}
if (Object.keys(fields).length) body.fields = fields;
// Nothing differs: usually the echo of Flow 1 (ID written back) or Flow 3 (Tomorro values written back)
if (!Object.keys(body).length) return { changed: false };
return { changed: true, body: JSON.stringify(body) };
```
**5. Step 2.2 - Update the counterparty in Tomorro**
*Make module 7*, filtered on `changed` = `true`, with a *Retry* error handler.
```http theme={null}
PATCH https://api.tomorro.com/v2/counterparties/{tomorroId}
x-api-key:
Content-Type: application/json
```
### Flow 3 - \[Tomorro → Notion] Counterparty updated in Tomorro
Make scenario *Flow 3 - Update from Tomorro*, every day at 8:00.
**1. Step 0 - Get the last updatedAt processed**
*Make module 1*
*Data store > Get a record*, key `flow3_last_updated_at`. Empty on the first run.
**2. Step 1.1 - List the counterparties, latest updates first**
*Make module 2*
```http theme={null}
GET https://api.tomorro.com/v2/counterparties?sort=-updatedAt&limit=50
x-api-key:
```
**3. Step 1.2 - Keep the counterparties updated since the last run**
*Make module 3*
Inputs: the saved value of step 0, `data` and `pagination.has_next` of step 1.1.
```javascript theme={null}
const { lastUpdatedAt, counterparties, hasNext } = input;
// First run: look back 24 hours
const since = lastUpdatedAt ? new Date(lastUpdatedAt) : new Date(Date.now() - 24 * 60 * 60 * 1000);
// The list is sorted by updatedAt, newest first: keep what changed since the last run
const changed = (counterparties || []).filter((c) => new Date(c.updatedAt) > since);
const newest = changed.length ? changed[0].updatedAt : since.toISOString();
const warning = hasNext && changed.length === (counterparties || []).length
? 'More counterparties changed than one page holds: paginate with pagination.next_cursor'
: null;
return { counterparties: changed, newLastUpdatedAt: newest, warning };
```
**4. Step 1.3 - Remember the latest updatedAt**
*Make module 4*
*Data store > Add/replace a record*, key `flow3_last_updated_at`, value `newLastUpdatedAt` of step 1.2.
**5. Step 1.4 - Get the Industry option ids**
*Make module 5*
Same call as Flow 0 step 1, once per run.
**6. Step 2.1 - Find the ERP counterparty**
*Make modules 6, 7*
Iterate over the counterparties of step 1.2, and look up the Notion row whose `ID tomorro` is the counterparty `id`:
```http theme={null}
POST https://api.notion.com/v1/databases/{databaseId}/query
Notion-Version: 2022-06-28
{ "filter": { "property": "ID tomorro", "rich_text": { "equals": "" } }, "page_size": 1 }
```
**7. Step 2.2 - Compare Tomorro and Notion**
*Make module 8*
Inputs: the rows of step 2.1, the counterparty `name` and `fields`, the options of step 1.4. Skips a Tomorro counterparty when no ERP row has its id, translates the Industry option id back to its label, and builds the Notion properties that differ.
```javascript theme={null}
const { rows, counterpartyName, counterpartyFields, industryOptions } = input;
// Counterparty not linked to any ERP counterparty: created in Tomorro only, ignored
const row = (rows || [])[0];
if (!row) return { changed: false, reason: 'no ERP counterparty with this ID tomorro' };
const p = row.properties || {};
const text = (prop) => ((prop && (prop.title || prop.rich_text)) || []).map((t) => t.plain_text).join('').trim();
const erp = {
name: text(p['Counterparty Name']),
siret: text(p['SIRET Number']),
industry: (p['Industry'] && p['Industry'].select && p['Industry'].select.name) || '',
address: text(p['Address']),
city: text(p['City']),
};
// Tomorro smart field ids: Dynamic components > Smart fields, last segment of the URL
const FIELDS = {
siret: '9d6175af-86a5-497a-aa42-facfb2b8dafb',
industry: 'b25c969a-2a1c-4903-be86-10269aeb5e84',
address: '0abf8d86-02ab-4583-aea3-b730ad802c37',
city: 'a6690cab-baaa-4848-8866-a03d800049e3',
};
// Tomorro stores a picklist value as the option id, not its label: translate it back
const labelByOptionId = {};
for (const option of industryOptions || []) {
for (const translation of option.translations || []) {
const label = String(translation.value || '').trim();
if (label && !labelByOptionId[option.id]) labelByOptionId[option.id] = label;
}
}
const currentFields = counterpartyFields || {};
const clean = (value) => (value === null || value === undefined ? '' : String(value).trim());
const tomorroValue = (fieldId) => {
const field = currentFields[fieldId];
return clean(field && typeof field === 'object' && 'value' in field ? field.value : field);
};
const rawIndustry = tomorroValue(FIELDS.industry);
const tomorro = {
name: clean(counterpartyName),
siret: tomorroValue(FIELDS.siret),
// undefined when the stored value is not a known option: leave the ERP alone
industry: rawIndustry ? labelByOptionId[rawIndustry] : '',
address: tomorroValue(FIELDS.address),
city: tomorroValue(FIELDS.city),
};
const richText = (value) => ({ rich_text: value ? [{ text: { content: value } }] : [] });
const toNotion = {
name: (value) => ['Counterparty Name', { title: [{ text: { content: value } }] }],
siret: (value) => ['SIRET Number', richText(value)],
industry: (value) => ['Industry', { select: value ? { name: value } : null }],
address: (value) => ['Address', richText(value)],
city: (value) => ['City', richText(value)],
};
const properties = {};
const changedKeys = [];
for (const key of Object.keys(toNotion)) {
if (tomorro[key] === undefined) continue;
if (clean(erp[key]).toLowerCase() === tomorro[key].toLowerCase()) continue;
// Counterparty Name is the Notion title: never blank it
if (key === 'name' && !tomorro.name) continue;
const [propertyName, value] = toNotion[key](tomorro[key]);
properties[propertyName] = value;
changedKeys.push(key);
}
if (!changedKeys.length) return { changed: false, pageId: row.id };
return { changed: true, pageId: row.id, changedKeys: changedKeys.join(','), body: JSON.stringify({ properties }) };
```
**8. Step 2.3 - Update the counterparty in Notion**
*Make module 9*, filtered on `changed` = `true`.
```http theme={null}
PATCH https://api.notion.com/v1/pages/{pageId}
Notion-Version: 2022-06-28
```
### Make blueprints
A blueprint is a Make scenario exported as JSON. Import it and every module comes back configured, code included.
> **[Get the 4 Make blueprints](https://drive.google.com/drive/folders/1YXxdcyQ7cBKlkSu5FGwl0nZx5Vk5-t0U)**
>
> The *Sync counterparties - Flow 0 to 3* files: Flow 0 - Initialisation, Flow 1 - Creation, Flow 2 - Update from the ERP and Flow 3 - Update from Tomorro, one JSON file each.
**1. Import**
In Make, create a scenario, open the **⋯** menu, choose **Import blueprint** and select one of the downloaded JSON files. Repeat for each flow.
**2. Connect**
* **Tomorro HTTP modules**: create an *API Key Auth* keychain: key name `x-api-key`, value your API key, placed in the header.
* **Notion modules**: select a Notion connection that has access to your counterparty database.
* **Flows 1 and 2**: on the *Watch Events* trigger, create a webhook with the event **Page: Properties updated**, one per scenario.
* **Flow 3**: on both *Data store* modules, create a data store with a `key` and a text `value`.
**3. Replace the ids**
Replace `YOUR_NOTION_DATABASE_ID` and `YOUR_NOTION_DATA_SOURCE_ID` with your database (Notion API calls and the webhook filters), and the smart field ids (URLs and `FIELDS` in the Code modules) with yours.
**4. Run**
Run Flow 0 once, check a few counterparties in Tomorro, then turn on Flows 1, 2 and 3.
# Non-Technical scoping
Source: https://alpha.developer.tomorro.com/guides/sync-your-counterparties/non-technical-scoping
Sync your counterparties: what the business owner decides, and the brief to hand to IT
**Goal:** your ERP stays the reference for counterparties. Every counterparty created or edited there shows up in Tomorro, ready to pick in any contract. Nobody types it twice.
**TL;DR:** three small flows, run by a no-code tool (Make, n8n) or a few lines of code.
* **Flow 1: Counterparty created in the ERP.**
* **Flow 2: Counterparty updated in the ERP.**
* **Flow 3: Counterparty updated in Tomorro.**

**For the business owner.** A few decisions, no technical skills needed. Fill in the brief below and send it to your IT team or integrator: with the [Technical recipes](/guides/sync-your-counterparties/technical-recipes), it has everything they need.
## Brief for your IT team
Copy this brief, fill in the blanks, and send it to your IT team or integrator. **It is all they need from you.**
```markdown Brief for IT theme={null}
Subject: ERP x Tomorro counterparty sync — ready to build
Hi,
I have done the business part of the ERP x Tomorro counterparty sync. Over to you:
everything you need to build it is below.
1. Scope
- Counterparties to sync: ______________________ (e.g. active counterparties of the French entity)
- Counterparties are created in: ______________________ (e.g. the ERP only)
- Tomorro users may correct counterparty data: yes / no (no = Flow 3 not needed)
- Same-day conflict, the winner is: ERP / Tomorro
2. Data mapping (ERP field -> Tomorro field)
- ______________________ -> Counterparty name (required)
- ______________________ -> ______________________
- ______________________ -> ______________________
- ______________________ -> ______________________
- New ERP column "ID tomorro" -> filled by the integration
3. Timing
- Flow 1 - Counterparty created in the ERP: ______________________ (e.g. as soon as the counterparty is created)
- Flow 2 - Counterparty updated in the ERP: ______________________ (e.g. as soon as the counterparty changes)
- Flow 3 - Counterparty updated in Tomorro: ______________________ (e.g. once a day, every morning at 8:00)
4. Done on my side
- [ ] Missing fields created in Tomorro, with the same list values as the ERP
Your part (all in the technical recipes):
- Technical recipes: https://alpha.developer.tomorro.com/guides/sync-your-counterparties/technical-recipes
- Ready-to-import Make scenarios: https://alpha.developer.tomorro.com/guides/sync-your-counterparties/full-cycle-example#make-blueprints
- API key: Tomorro > Settings > Integrations > API key, from a dedicated superadmin account
Thanks!
```
## Help to complete the brief
### 1 - Scope
| Question | Example answer | Why it matters |
| - | - | - |
| Which counterparties go to Tomorro? | Active counterparties of the French entity | Only these show up in contracts |
| Where are counterparties created? | In the ERP only | One source of truth, no duplicates |
| Can Tomorro users correct counterparty data? | Yes | If not, Flow 3 is not needed |
| Both sides changed the same day: who wins? | The ERP | No conflict to manage by hand |
### 2 - Data mapping
One line per counterparty field you want in Tomorro.
> [!NOTE]
>
> Example only: list your own ERP fields and the Tomorro fields they should fill.
| ERP field | Tomorro field | Rule |
| - | - | - |
| Supplier name | Counterparty name | Required |
| SIRET | Registration number | Copied as is |
| Industry | Industry (list) | Same list of values in both tools |
| Address | Address | Copied as is |
| City | City of registration | Copied as is |
| ID tomorro | - | New ERP column, filled automatically |
### 3 - Timing
When should each automation run, and what starts it?
> [!NOTE]
>
> Example only: choose the timing that fits your process.
| Flow | What starts it | When it runs |
| - | - | - |
| Flow 1 - Counterparty created in the ERP | Every counterparty created in the ERP | As soon as the counterparty is created in the ERP |
| Flow 2 - Counterparty updated in the ERP | Every change in the ERP | As soon as the counterparty changes in the ERP |
| Flow 3 - Counterparty updated in Tomorro | Every change in Tomorro | Once a day, every morning at 8:00 |
### 4 - Checklist
* [ ] Scope (1), mapping (2) and timing (3) validated
* [ ] Missing fields created in Tomorro (**Libraries > Dynamic components > Smart fields**), with the same Industry values as the ERP
* [ ] An IT contact or integrator to build the flows, and the brief sent to them
# Technical recipes
Source: https://alpha.developer.tomorro.com/guides/sync-your-counterparties/technical-recipes
Sync your counterparties: prerequisites, the four flows and the API reference for your IT team
Everything runs on the Tomorro REST API: three counterparty endpoints and one picklist endpoint. How you read and write the ERP depends on your ERP.
```mermaid theme={null}
sequenceDiagram
autonumber
actor U as ERP user
participant E as ERP
counterparty base
participant I as Integration
customer middleware
participant T as Tomorro
counterparties
Note over U,T: Prerequisites: "automation" superadmin account, API key, counterparty smart fields, "ID tomorro" column in the ERP
rect rgb(232, 244, 253)
Note over U,T: Flow 0: Initialisation, run once by hand (ERP -> Tomorro -> ERP)
I->>T: GET /v2/smart-fields/{id}/options (picklist option ids)
T-->>I: options
I->>E: List counterparties with an empty ID tomorro
E-->>I: counterparties
loop Each counterparty
I->>T: POST /v2/counterparties
name + fields (smart field id -> value)
T-->>I: 201 data.id
I->>E: Write data.id into ID tomorro
end
end
rect rgb(232, 244, 253)
Note over U,T: Flow 1: Counterparty created in the ERP (ERP -> Tomorro -> ERP)
U->>E: Creates a counterparty
E-->>I: New counterparty (event or polling)
I->>T: POST /v2/counterparties
T-->>I: 201 data.id
I->>E: Write data.id into ID tomorro
end
rect rgb(243, 229, 245)
Note over U,T: Flow 2: Counterparty updated in the ERP (ERP -> Tomorro)
U->>E: Edits a counterparty
E-->>I: Updated counterparty (event or polling)
I->>T: GET /v2/counterparties/{ID tomorro}
T-->>I: current name + fields
opt Something differs
I->>T: PATCH /v2/counterparties/{ID tomorro}
only the changed values
T-->>I: 200
end
end
rect rgb(245, 229, 164)
Note over U,T: Flow 3: Counterparty updated in Tomorro, daily at 8:00 (Tomorro -> ERP)
I->>T: GET /v2/counterparties?sort=-updatedAt
T-->>I: counterparties, latest updates first
Note over I: Keep those updated since the last run
loop Each updated counterparty
I->>E: Find the ERP row by ID tomorro
opt Something differs
I->>E: Update the ERP row with the Tomorro values
end
end
end
```
## 1. Technical prerequisites
**1. 1.1 - Create Tomorro superadmin account**
An existing admin creates a dedicated **superadmin** account from **Settings > Members** (e.g. `automation@customer.com`), not a personal account.
* The API key is bound to a **member**, not to the organization. Every write performed through the API is attributed to that member and inherits its permissions.
* Consequence: the counterparty history shows this account as the author of every creation and update made by the integration. A named account such as "ERP automation" keeps it readable.
* Recommended: no personal mailbox behind it, and exclude it from Tomorro notification settings.
**2. 1.2 - API key**
Generate the API key from that superadmin account. It does not expire. It is sent on every REST call in the `x-api-key` header: [Step 1: Get your API key](/quickstart)
**3. 1.3 - Counterparty smart fields in Tomorro**
Every ERP field other than the name needs a smart field on the counterparty.
* **Libraries > Dynamic components > Smart fields > New**, with **Data related to** = *Counterparty smart field*.
* Pick the type matching the ERP: *Text* for SIRET, address and city, *Single-choice selector* for the industry (options = the ERP values, see 3.5).
* Copy each smart field id from the URL of the field (last segment) next to your mapping ([Non-Technical scoping](/guides/sync-your-counterparties/non-technical-scoping)):
| Mapping | API name |
| - | - |
| Counterparty name | `name` |
| SIRET | `9d6175af-86a5-497a-aa42-facfb2b8dafb` |
| Industry | `b25c969a-2a1c-4903-be86-10269aeb5e84` (option id, see 3.5) |
| Address | `0abf8d86-02ab-4583-aea3-b730ad802c37` |
| City | `a6690cab-baaa-4848-8866-a03d800049e3` |
| ID tomorro | `id` (response) |
**4. 1.4 - ERP: add the ID tomorro column**
Add a **text** column `ID tomorro` to the counterparty table, empty for every counterparty. The integration fills it; users must not edit it. If the ERP supports it, make it read-only for users.
## 2. Flows
### Flow 0 - \[ERP → Tomorro → ERP] Initialisation
**1. Step 0 - Triggered by hand**
Run once, when the integration goes live. Take every counterparty whose `ID tomorro` is empty (and that matches your scope, see [Non-Technical scoping](/guides/sync-your-counterparties/non-technical-scoping)). Re-running it is safe: linked counterparties are skipped.
**2. Step 1 - Get the picklist option ids**
Once per run, for each picklist smart field:
```http theme={null}
GET https://api.tomorro.com/v2/smart-fields/b25c969a-2a1c-4903-be86-10269aeb5e84/options?limit=50
x-api-key:
```
Response `200`:
```json theme={null}
{
"data": [
{ "id": "7c1e2f0a-...", "translations": [ { "language": "gb", "value": "Logistics" } ], "integrationMappings": [] },
{ "id": "0d93b6c4-...", "translations": [ { "language": "gb", "value": "Energy" } ], "integrationMappings": [] }
],
"pagination": { "limit": 50, "next_cursor": null, "has_next": false, "has_previous": false }
}
```
> **Notes**
>
> * Build a `label → id` table from `data[].translations[].value` and `data[].id`. Match labels case-insensitively.
> * Up to 50 options per page: follow `pagination.next_cursor` with `after` if there are more.
> * `integrationMappings` can hold your ERP codes for each option, to translate on codes instead of labels.
**3. Step 2 - Create the counterparty in Tomorro, one call per ERP row**
```http theme={null}
POST https://api.tomorro.com/v2/counterparties
x-api-key:
Content-Type: application/json
{
"name": "Acme Logistics",
"fields": {
"9d6175af-86a5-497a-aa42-facfb2b8dafb": "41234567800012",
"b25c969a-2a1c-4903-be86-10269aeb5e84": "7c1e2f0a-...",
"0abf8d86-02ab-4583-aea3-b730ad802c37": "12 rue de la Paix",
"a6690cab-baaa-4848-8866-a03d800049e3": "Paris"
}
}
```
Response `201`:
```json theme={null}
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Acme Logistics",
"status": "active",
"logoUrl": null,
"fields": {
"b25c969a-2a1c-4903-be86-10269aeb5e84": { "type": "select", "value": "7c1e2f0a-..." },
"a6690cab-baaa-4848-8866-a03d800049e3": { "type": "string", "value": "Paris" }
},
"createdAt": "2026-09-30T08:00:00.000Z",
"updatedAt": "2026-09-30T08:00:00.000Z"
}
}
```
> **Notes**
>
> * `name` is the only required field. `status` defaults to `active`.
> * `fields` keys are **smart field ids** (1.3). Text values as strings, picklists as the **option id** (step 1, 3.5). Leave a field out rather than sending an empty string.
> * The body is strict: an unknown top-level key is refused with a `400`. An unknown smart field id is refused too.
> * Read the new id under `data.id`. In the response, each field comes back as `{ type, value }`, one entry per counterparty smart field of the workspace, filled or not.
> * **Duplicates**: Tomorro does not deduplicate counterparties. Calling this endpoint twice creates two counterparties. This is why Flows 0 and 1 only take counterparties with an empty `ID tomorro`.
**4. Step 3 - Write the Tomorro id back to the ERP**
Out of scope (depends on the ERP). Write `data.id` from step 2 into the `ID tomorro` column, straight away: until it is written, a new run would create the counterparty a second time.
> [!TIP]
>
> **Rate limit**: no need to slow the calls down. If Tomorro answers `429`, retry with backoff: the Make example retries automatically, 3 times, 1 minute apart.
### Flow 1 - \[ERP → Tomorro → ERP] Counterparty created in the ERP
**1. Step 0 - Triggered by the ERP**
Out of scope. A counterparty is created in the ERP: ERP event or webhook if it has one, otherwise poll the counterparties created since the last run. Skip it if its `ID tomorro` is already filled or its name is empty.
**2. Steps 1 to 3 - Same as Flow 0**
Get the option ids, create the counterparty, write `data.id` back to the ERP.
### Flow 2 - \[ERP → Tomorro] Counterparty updated in the ERP
**1. Step 0 - Triggered by the ERP**
Out of scope. A counterparty is updated in the ERP. Only counterparties with an `ID tomorro` go further.
**2. Step 1 - Read the current counterparty and the option ids**
```http theme={null}
GET https://api.tomorro.com/v2/counterparties/{ID tomorro}
x-api-key:
```
Response `200`: same shape as the creation response. Read `data.name` and `data.fields[""].value`. Get the option ids as in Flow 0 step 1.
**3. Step 2 - Compare, then update only what changed**
Compare every mapped ERP value with the Tomorro value (picklists on the option id). If nothing differs, **stop**: the ERP change was on another column, or it is the echo of Flow 1 or Flow 3 (3.3). Otherwise:
```http theme={null}
PATCH https://api.tomorro.com/v2/counterparties/{ID tomorro}
x-api-key:
Content-Type: application/json
{
"fields": {
"b25c969a-2a1c-4903-be86-10269aeb5e84": "0d93b6c4-...",
"a6690cab-baaa-4848-8866-a03d800049e3": null
}
}
```
Response `200`: the updated counterparty.
> **Notes**
>
> * Only the keys you send change. Send `name` only if it changed, and only the smart fields that changed.
> * `null` **clears** a smart field (the value was emptied in the ERP).
> * `404`: the counterparty no longer exists in Tomorro (deleted, or wrong `ID tomorro`). Apply the rule chosen in 3.6.
> * To archive a counterparty deactivated in the ERP, send `{ "status": "archived" }`; `{ "status": "active" }` restores it.
### Flow 3 - \[Tomorro → ERP] Counterparty updated in Tomorro
**1. Step 0 - Scheduled, every day at 8:00**
Read the last `updatedAt` you processed, saved at the end of the previous run. On the first run, look back 24 hours.
**2. Step 1 - List the counterparties updated since the last run**
One call returns the latest updates first:
```http theme={null}
GET https://api.tomorro.com/v2/counterparties?sort=-updatedAt&limit=50
x-api-key:
```
Keep the counterparties whose `updatedAt` is after the saved value, then save the newest `updatedAt` for the next run. Get the option ids once (Flow 0 step 1).
> **Notes**
>
> * `updatedAt` moves on every change of the counterparty, smart fields included.
> * If all 50 counterparties of the page changed since the last run, more are waiting: follow `pagination.next_cursor` with `after`.
> * Counterparties changed by Flow 2 come back here too: the comparison of step 2 finds nothing to write (3.3).
**3. Step 2 - Update the ERP with the values that differ**
Out of scope (depends on the ERP). For each Tomorro counterparty, find the ERP row whose `ID tomorro` equals that counterparty's `id`. No row: the counterparty exists only in Tomorro, ignore it (see [Non-Technical scoping](/guides/sync-your-counterparties/non-technical-scoping)). Otherwise translate picklist option ids back to labels, compare with the ERP and write only the differences. No difference, no write: an unnecessary write would fire Flow 2 again.
## 3. Technical references
Reference material: conventions, the counterparty object and error handling. Read it once, then come back to it while implementing the flows above.
### 3.1 - API conventions
* **REST only.** The whole integration runs on the public REST API.
* **Base URL**: `https://api.tomorro.com/v2`
* **Authentication**: `x-api-key: ` on every call. A missing or unknown key returns 401.
* **Versioning**: optional `tomorro-version` header, defaults to the latest version.
* **Rate limit**: a `429` means too many calls in a short time. Retry with backoff.
* **Sandbox**: available on request. It is an empty sandbox, not a copy of your production workspace.
* **Content type**: `application/json`
* **Response envelope**: every **successful** JSON response is wrapped in a `data` object (errors come as `{ error, meta }`, see 3.4). Read `data.id`, `data.fields` and so on.
* **Full reference**: [Counterparties - Tomorro API](/api-reference/introduction)
### 3.2 - Counterparty endpoints and fields
| Endpoint | Use in this integration |
| - | - |
| `POST /v2/counterparties` | Flows 0, 1: create. Returns `201` with the counterparty. |
| `GET /v2/counterparties/{id}` | Flows 2, 3: read the current values. |
| `PATCH /v2/counterparties/{id}` | Flow 2: update. Only the keys sent change. Returns `200`. |
| `GET /v2/smart-fields/{id}/options` | All flows: picklist option ids and labels. `limit` 1 to 50, cursor pagination, `term` filters on the label. |
| `GET /v2/counterparties` | Flow 3: `sort=-updatedAt` returns the latest updates first. `limit` 1 to 50, cursor pagination, `name` (partial match), `status`. Also useful to spot duplicates before Flow 0. |
| Field | Values | Note |
| - | - | - |
| `name` | string, required on creation | The counterparty name shown everywhere in Tomorro. |
| `status` | `active` , `archived` | Default `active`. Archive rather than delete. |
| `fields` (request) | `{ "": value }` | Text as a string, picklist as the option id, multi-choice as an array of option ids, `null` to clear. |
| `fields` (response) | `{ "": { type, value } }` | One entry per counterparty smart field of the workspace. `value` is `null` when empty, an option id for a picklist. |
| `fields[].type` | `string` , `number` , `date` , `select` , `multiselect` , `duration` , `rich_text` , `file` | Type of the smart field. |
| `updatedAt` | ISO 8601 | Moves on every change of the counterparty, smart fields included. Flow 3 relies on it. |
### 3.3 - Loops and conflicts
Flows 1 and 3 write into the ERP (so Flow 2 sees an updated counterparty), Flow 2 writes into Tomorro (so Flow 3 reads a different counterparty). Without care, a change bounces forever.
* **Compare before writing, in both directions.** Flow 2 only sends a `PATCH` when an ERP value differs from Tomorro; Flow 3 only writes the ERP when a Tomorro value differs. The echo of a write finds identical values and stops.
* **Compare normalised values**: trim spaces, treat empty and `null` the same, compare picklists on the option id.
* **Same-day conflict**: if a field is changed in both systems between two runs of Flow 3, the ERP value wins, since Flow 2 runs first.
### 3.4 - Error handling
Every endpoint follows the standard error format of the REST API.
**Error envelope**
```json theme={null}
{
"error": {
"statusCode": "400",
"errorId": "optional, present on some domain errors",
"message": "Human readable message",
"details": [
{ "field": "name", "message": "..." }
]
},
"meta": {
"timestamp": "2026-09-30T10:09:22.634Z",
"requestId": "req_Ab12Cd34"
}
}
```
* `error.statusCode` is a **string**, not a number.
* `meta.requestId` is the value to quote to Tomorro support when reporting a failing call. Log it.
| Code | Meaning | Retry? |
| - | - | - |
| `400` | Invalid body: missing `name`, unknown key, wrong value type. | No, fix the payload |
| `401` | Missing or unknown `x-api-key`. | No |
| `403` | The API key's account is not allowed to manage counterparties. | No, check 1.1 |
| `404` | Unknown counterparty id, or unknown smart field id in `fields`. | No, see 3.6 |
| `429` | Rate limit exceeded. | Yes, with backoff |
| `500` / timeout | Internal error. | Yes, with backoff. On a creation, check first that the counterparty was not created (`GET /v2/counterparties?name=...`) to avoid a duplicate. |
### 3.5 - Picklists
* A picklist value is stored as the **option id**, not its label. Send `"Logistics"` and the API accepts it, but Tomorro shows an empty field.
* Get the ids with `GET /v2/smart-fields/b25c969a-2a1c-4903-be86-10269aeb5e84/options` and translate label ↔ id in the integration, in both directions.
* Align the labels on both sides first (here, `Software & Saas` in Tomorro renamed `Software & SaaS`). A label missing from Tomorro is left empty, never invented.
### 3.6 - Edge cases
* **Link**: an `ID tomorro` column in the ERP. Never match on name or SIRET.
* **Existing counterparties** before Flow 0: clean them up, or link them by filling `ID tomorro` by hand (Flow 0 skips linked rows).
* **Counterparty deactivated in the ERP**: archive the counterparty (`status: archived`) or ignore.
* **Counterparty deleted in Tomorro**: Flows 2 and 3 get a `404`. Alert, or clear `ID tomorro`.
* **Loops**: every update flow compares before writing, so the echo of a write stops by itself (3.3).
* **Triggers**: prefer an ERP webhook or event, so Flows 1 and 2 run as soon as a counterparty changes. Without one, poll the counterparties changed since the last run, e.g. every 15 minutes.
# Connect your Tomorro account
Source: https://alpha.developer.tomorro.com/mcp/connection
Sign in to Tomorro from your AI assistant, once per member
Once your administrator has [installed Tomorro MCP](/mcp/install), each member connects their own
Tomorro account from the assistant.
> [!NOTE]
>
> **Every member does this for themselves, only once.** The connection is personal: the assistant
> acts with your Tomorro account, so it only sees and changes what you are already allowed to see
> and change in Tomorro.
## Claude
[Tomorro x Claude — Connect your Tomorro account](https://demo.arcade.software/AryK2jdBPClY8CgtMlVF?embed\&embed_mobile=tab\&embed_desktop=inline\&show_copy_link=true)
Can't see the demo? [Open it in a new tab](https://app.arcade.software/share/AryK2jdBPClY8CgtMlVF).
## ChatGPT
[Tomorro x ChatGPT — Connect your Tomorro account](https://demo.arcade.software/cAficDb1kmZZnSDaidRD?embed\&embed_mobile=tab\&embed_desktop=inline\&show_copy_link=true)
Can't see the demo? [Open it in a new tab](https://app.arcade.software/share/cAficDb1kmZZnSDaidRD).
## Other assistants
Open the Tomorro connector your administrator added, click **Connect**, and sign in with your
Tomorro account. If you belong to several Tomorro workspaces, choose the one the assistant should
work in.
# Install Tomorro MCP
Source: https://alpha.developer.tomorro.com/mcp/install
Add the Tomorro MCP server to your AI assistant, once for the whole workspace
The Tomorro MCP server is a hosted service: there is nothing to install on your machine. You add it
once in your AI assistant, then each member connects their own Tomorro account.
> [!NOTE]
>
> **Only the administrator of your AI assistant's workspace needs to do this**, and only once.
> Once Tomorro is added, it is available to every member of the workspace, who then
> [connect their own account](/mcp/connection).
## What you'll need
| Setting | Value |
| - | - |
| **MCP server URL** | `https://api.tomorro.com/v2/mcp` |
| **Authentication** | OAuth — each member signs in with their Tomorro account when connecting |
## Claude
[Tomorro x Claude — Install Tomorro MCP](https://demo.arcade.software/i80NZEqenjstol4awvSI?embed\&embed_mobile=tab\&embed_desktop=inline\&show_copy_link=true)
Can't see the demo? [Open it in a new tab](https://app.arcade.software/share/i80NZEqenjstol4awvSI).
### Allow contract file downloads
To let Claude download contract documents (for example the Word version of a contract), add the
Tomorro file storage domains to your Claude organization's domain allowlist. In **Settings >
Capabilities > Domain allowlist**, add both domains under **Additional allowed domains**:
```
tomorro-prod-temporary-storage.s3.eu-west-3.amazonaws.com
```
```
s3.eu-west-3.amazonaws.com
```

## ChatGPT
[Tomorro x ChatGPT — Install Tomorro MCP](https://demo.arcade.software/BdU3Jtpmah4CpTFfzgL2?embed\&embed_mobile=tab\&embed_desktop=inline\&show_copy_link=true)
Can't see the demo? [Open it in a new tab](https://app.arcade.software/share/BdU3Jtpmah4CpTFfzgL2).
**Official guide:** [Developer mode, apps and full MCP connectors in ChatGPT](https://help.openai.com/en/articles/12584461-developer-mode-apps-and-full-mcp-connectors-in-chatgpt-beta)
## Other assistants
Each assistant documents how to add a remote MCP server with OAuth. Follow their guide with the
Tomorro server URL above.
| Assistant | Official guide |
| - | - |
| **Dust** | [Adding an MCP Server](https://docs.dust.tt/docs/remote-mcp-server) |
| **Notion** | [Connect MCP servers to your Notion Agent](https://www.notion.com/help/connect-mcp-servers-to-your-notion-agent) |
| **Gemini** | [Set up your custom MCP server — Gemini Enterprise](https://docs.cloud.google.com/gemini/enterprise/docs/connectors/custom-mcp-server/set-up-custom-mcp-server) |
| **Copilot** | [Connect your agent to an existing MCP server — Microsoft Copilot Studio](https://learn.microsoft.com/en-us/microsoft-copilot-studio/mcp-add-existing-server-to-agent) |
Once Tomorro is installed, share the [connection guide](/mcp/connection) with your team.
# Overview
Source: https://alpha.developer.tomorro.com/mcp/overview
Bring your contracts into the AI assistant you already use — to read and to act
Connect Tomorro's MCP server to bring your contracts into the AI assistant you already use. Your
assistant can now **read** your Tomorro data and **act** on it: create a contract, update a
counterparty, upload a signed version, send a contract for signature, and more.
Each person connects with their own Tomorro credentials, so the assistant only sees and changes
what that user is already allowed to see and change in Tomorro.
## What is Tomorro's MCP capable of?
The Tomorro MCP server is a secure bridge to our public REST API. Once connected, your AI
assistant can explore and manage your Tomorro workspace through natural language, bringing
structured contract data directly into your conversations. Responses are trimmed to what the
assistant needs, so answers come back faster and long conversations stay focused.
[Tomorro MCP presentation](https://www.loom.com/embed/debf3ebf550845df809df606ae72fa9a)
### Tools in detail

> **All 39 tools in detail**
>
> | Area | Tool | Access | Description |
> | - | - | - | - |
> | Contracts | `tomorro_list_contracts` | Read | Search and list contracts |
> | Contracts | `tomorro_get_contract` | Read | Retrieve a contract's metadata, fields, signatories and members |
> | Contracts | `tomorro_get_consolidated_contract` | Read | Retrieve a contract with field values updated by its amendments |
> | Contracts | `tomorro_get_contract_clauses` | Read | List the clauses attached to a contract |
> | Contracts | `tomorro_get_contract_current_version` | Read | Download the current working document (PDF or Word) |
> | Contracts | `tomorro_get_contract_signed_files` | Read | Download the signed PDFs of a signed contract |
> | Contracts | `tomorro_create_contract` | Write | Create a contract from a contract type and a template |
> | Contracts | `tomorro_update_contract` | Write | Update a contract's fields, counterparty, signatories or members |
> | Contracts | `tomorro_create_contract_version` | Write | Upload a new version of a contract's document |
> | Contracts | `tomorro_add_contract_attachment` | Write | Attach a supporting file (annex, certificate…) to a contract |
> | Contracts | `tomorro_request_contract_signatures` | Write | Send a contract for signature |
> | Contracts | `tomorro_upload_contract_signed_version` | Write | Mark a contract as signed from PDFs signed outside Tomorro |
> | Files | `tomorro_create_file_upload_url` | Write | Get a secure link to upload a file before attaching it to a contract |
> | Contract types | `tomorro_list_contract_types` | Read | List the contract types available |
> | Contract types | `tomorro_get_contract_type_templates` | Read | List the templates of a contract type, by language |
> | Contract types | `tomorro_get_contract_type_creation_form` | Read | Return the smart fields required to create a contract |
> | Contract types | `tomorro_get_contract_type_template_content` | Read | Read the wording of a template |
> | Smart fields | `tomorro_list_smart_fields` | Read | List smart fields |
> | Smart fields | `tomorro_get_smart_field_options` | Read | List the options of a select smart field |
> | Custom objects | `tomorro_list_custom_objects` | Read | List the custom objects |
> | Custom objects | `tomorro_get_custom_object` | Read | Retrieve a custom object with its smart fields |
> | Custom objects | `tomorro_list_contract_records` | Read | List the records a contract holds for a custom object |
> | Custom objects | `tomorro_upsert_contract_records` | Write | Create or update the records a contract holds for a custom object |
> | Clauses | `tomorro_list_clauses` | Read | List all clauses of the library |
> | Clauses | `tomorro_get_clause` | Read | Retrieve a clause with its content |
> | Clauses | `tomorro_create_clause` | Write | Add a clause to the library |
> | Counterparties | `tomorro_list_counterparties` | Read | Search and list counterparties |
> | Counterparties | `tomorro_get_counterparty` | Read | Retrieve a counterparty's profile |
> | Counterparties | `tomorro_create_counterparty` | Write | Create a counterparty |
> | Counterparties | `tomorro_update_counterparty` | Write | Update a counterparty's name, status or fields |
> | Members | `tomorro_list_members` | Read | List members, filtered by role, status or email |
> | Members | `tomorro_get_member` | Read | Retrieve a member with their role and groups |
> | Members | `tomorro_get_member_out_of_office` | Read | Check whether a member is out of office |
> | Members | `tomorro_create_member` | Write | Add a member and optionally send an invitation |
> | Members | `tomorro_update_member` | Write | Update a member's role, status, groups or folders |
> | Members | `tomorro_set_member_out_of_office` | Write | Set your own out-of-office period |
> | Members | `tomorro_disable_member_out_of_office` | Write | End your own out-of-office period |
> | Organization | `tomorro_get_organization` | Read | Retrieve the organization you are connected to |
> | Audit logs | `tomorro_list_audit_logs` | Read | Review who did what in your workspace (admins only) |
### Examples & Usecases
[Tomorro MCP use cases](https://www.loom.com/embed/36edf98ea5d04f4b8abf321dcbffe6a8)
* "Which contracts are expiring in the next 90 days?"
* "Show me all NDAs signed with Acme Corp this year."
* "Create an NDA with Acme Corp in English, with Jane Doe as internal signatory."
* "What's the status of the master agreement with supplier X?"
* "Send the service agreement with supplier X for signature."
* "Which clauses are attached to contract `ctr_xxx`?"
* "Add our new confidentiality clause to the clause library."
* "Here are the PDFs signed in DocuSign — mark the partnership agreement as signed."
* "Archive the counterparty Old Supplier Ltd."
* "Invite [john@acme.com](mailto:john@acme.com) as a member with the USER role."
* "I'm on leave from August 1st to August 15th, set my out-of-office."
* "Which smart fields are required to create a 'Service Agreement' contract?"
# Overview
Source: https://alpha.developer.tomorro.com/webhooks/overview
Receive real-time HTTP notifications when events happen in Tomorro
Webhooks let Tomorro notify your systems in real time when something happens in your
organization — instead of polling the API. When a subscribed event occurs, Tomorro sends an
HTTP `POST` request to a URL you control, with the event details in the body.
## Available events
A webhook can be triggered by any of the following actions:
| Event type | Triggered when |
| - | - |
| `contractCreated` | A contract is created |
| `contractDeleted` | A contract is deleted |
| `contractSigned` | A contract is fully signed |
| `contractStatusUpdated` | A contract's status changes |
| `reminderEventCreated` | A reminder is triggered |
> [!NOTE]
>
> **Webhooks are personal.** A webhook only fires for events that concern the member who
> created it.
>
> * The `contractCreated` webhook only triggers if you are a participant of the contract.
> * The `reminderEventCreated` webhook only triggers if the reminder is intended for you.
## Create a webhook
**1. Open the Webhooks settings**
Go to **My account → Integrations → Webhooks**, or open it directly:
> **[Open Webhooks settings](https://app.tomorro.com/settings/integrations?integration=webhook)**
>
> app.tomorro.com → Settings → Integrations → Webhooks
**2. Add a webhook**
Click **Add a webhook**.
**3. Configure it**
1. **Name** your webhook.
2. Paste the **URL** that will receive the events.
3. Select one or more **triggers** (the events that activate it).
4. Click **Create webhook**.
**4. Save your signing secret**
A secret is generated for the webhook. Tomorro uses it to sign every event so you can
verify authenticity — keep it safe and use it to check the
[`Leeway-Signature`](/webhooks/technical-documentation#signature-header) header.
## Event payloads
Every webhook is delivered as an HTTP `POST` with a JSON body. All events share the same
top-level envelope; the event-specific content lives under `data`.
### Envelope
| Field | Type | Description |
| - | - | - |
| `eventId` | `string` | Unique identifier of this event delivery (UUID). |
| `webhookId` | `string` | Identifier of the webhook that produced the event (UUID). |
| `createdAt` | `string` | ISO 8601 timestamp of when the event was created. |
| `eventType` | `string` | One of the [available event types](#available-events). |
| `data` | `object` | The event-specific payload (see examples below). |
> [!NOTE]
>
> The `data` shape depends on `eventType`. Contract events wrap the contract under
> `data.contract`. Always branch on `eventType` before reading `data`.
### Examples
> **contractCreated**
>
> ```json theme={null}
> {
> "eventId": "b21213e3-8a9a-4e04-9bfc-c4e53f123xxx",
> "webhookId": "2a76094c-1f2e-48c8-a47f-1add41234xxx",
> "createdAt": "2025-03-25T14:55:16.280Z",
> "eventType": "contractCreated",
> "data": {
> "contract": {
> "id": "6954ad41-19f3-446f-b0db-fcc661234xxx",
> "name": "TestWebhook",
> "organizationId": "115e926b-9c61-4172-8609-02212344exxx",
> "author": {
> "id": "974d2a1c-eb63-4237-877e-37d12341axxx",
> "user": {
> "id": "fd61234d-a4e1-4103-8d31-59e351d2bxxx",
> "username": "user@example.com",
> "firstname": "John",
> "lastname": "Doe"
> }
> },
> "referent": {
> "id": "974d2a1c-eb63-4237-877e-37123451axxx",
> "user": {
> "id": "fd61234d-a4e1-4103-8d31-59e351d2bxxx",
> "username": "user@example.com",
> "firstname": "John",
> "lastname": "Doe"
> }
> },
> "referentId": "974d2a1c-eb63-4237-877e-37123451axxx",
> "supervisorId": null,
> "externalCompany": {
> "id": "01611234-7cdf-4785-966e-ee06b74ccxxx",
> "name": "My external company"
> },
> "priorNoticeDuration": "undefined",
> "priorNoticeValue": null,
> "renewal": "no",
> "signatureDate": null,
> "status": "draft",
> "createdAt": "2025-03-25T14:55:14.690Z",
> "updatedAt": "2025-03-25T14:55:14.000Z",
> "documentId": null,
> "typeId": null,
> "templateId": null,
> "contractAttributes": [
> { "attributeDefinition": { "name": "durationType" }, "value": "PERMANENT" },
> { "attributeDefinition": { "name": "endAt" }, "value": null },
> { "attributeDefinition": { "name": "externalPartyName" }, "value": "My external company" },
> { "attributeDefinition": { "name": "initialDuration" }, "value": null },
> { "attributeDefinition": { "name": "internalPartyName" }, "value": "John Doe" },
> { "attributeDefinition": { "name": "language" }, "value": "fr" },
> { "attributeDefinition": { "name": "nextRenewalDate" }, "value": null },
> { "attributeDefinition": { "name": "priorNotice" }, "value": null },
> { "attributeDefinition": { "name": "renewalDuration" }, "value": null },
> { "attributeDefinition": { "name": "renewalType" }, "value": "no" },
> { "attributeDefinition": { "name": "signatureDate" }, "value": null },
> { "attributeDefinition": { "name": "startAt" }, "value": null }
> ],
> "attributes": {
> "durationType": { "value": "PERMANENT", "name": "durationType", "attributeDefinitionId": "durationType" },
> "endAt": { "value": null, "name": "endAt", "attributeDefinitionId": "endAt" },
> "externalPartyName": { "value": "My external company", "name": "externalPartyName", "attributeDefinitionId": "externalPartyName" },
> "initialDuration": { "value": null, "name": "initialDuration", "attributeDefinitionId": "initialDuration" },
> "internalPartyName": { "value": "John Doe", "name": "internalPartyName", "attributeDefinitionId": "internalPartyName" },
> "language": { "value": "fr", "name": "language", "attributeDefinitionId": "language" },
> "nextRenewalDate": { "value": null, "name": "nextRenewalDate", "attributeDefinitionId": "nextRenewalDate" },
> "priorNotice": { "value": null, "name": "priorNotice", "attributeDefinitionId": "priorNotice" },
> "renewalDuration": { "value": null, "name": "renewalDuration", "attributeDefinitionId": "renewalDuration" },
> "renewalType": { "value": "no", "name": "renewalType", "attributeDefinitionId": "renewalType" },
> "signatureDate": { "value": null, "name": "signatureDate", "attributeDefinitionId": "signatureDate" },
> "startAt": { "value": null, "name": "startAt", "attributeDefinitionId": "startAt" }
> }
> }
> }
> }
> ```
> **contractStatusUpdated**
>
> Status-update events include the contract's lifecycle fields (`status`, `dateStatus`,
> `realEndDate`, …) and the resolved `attributes`, where each attribute carries a human-readable
> `valueLabel` in addition to its raw `value`.
>
> ```json theme={null}
> {
> "eventId": "dcdfce0c-5914-42b2-bcda-ce53b0afbed7",
> "webhookId": "a6493b01-0a87-4fd6-a851-8def5a76c9a1",
> "createdAt": "2026-06-10T13:08:43.801Z",
> "eventType": "contractStatusUpdated",
> "data": {
> "contract": {
> "id": "b9ce25f0-9b85-4c31-acf4-e6311e95xxxc",
> "name": "NDA - Adobe",
> "organizationId": "392ef54d-dc59-41f6-8ef9-37c6a1b7xxx3",
> "author": {
> "id": "53a9760f-ad03-4f18-b7e0-a52fe303xxx7",
> "user": {
> "id": "b30bb4d4-007c-4a93-b2e7-0be8b0bxxx9f",
> "username": "user@example.com",
> "firstname": "John",
> "lastname": "Doe"
> }
> },
> "referent": {
> "id": "53a9760f-ad03-4f18-b7e0-a52fe303xxx7",
> "user": {
> "id": "b30bb4d4-007c-4a93-b2e7-0be8b0xxx59f",
> "username": "user@example.com",
> "firstname": "John",
> "lastname": "Doe"
> }
> },
> "referentId": "53a9760f-ad03-4f18-b7e0-a52fe303xxx7",
> "supervisor": null,
> "supervisorId": null,
> "externalCompany": {
> "id": "e7d3d7d6-0f0b-4bdf-9233-7b7188aaxxx4",
> "name": "Adobe"
> },
> "durationType": "FIXED_TERM",
> "startAt": "2026-01-01T00:00:00.000Z",
> "endAt": "2026-01-31T00:00:00.000Z",
> "terminatedAt": null,
> "priorNoticeDuration": "undefined",
> "priorNoticeValue": null,
> "renewal": "no",
> "renewalType": "no",
> "renewalDuration": null,
> "renewalDurationUnit": null,
> "isRenewable": false,
> "signatureDate": null,
> "status": "negotiating",
> "dateStatus": "negotiating",
> "nextRenewedDate": null,
> "realEndDate": "2026-01-31T00:00:00.000Z",
> "createdAt": "2026-06-10T13:07:40.997Z",
> "updatedAt": "2026-06-10T13:08:43.000Z",
> "typeId": "7591d403-53df-41d4-8d5d-ec56aad4e375",
> "templateId": "74829184-734a-4f08-b0d5-9e36f67db126",
> "contractAttributes": [
> { "attributeDefinition": { "name": "Adresse du cocontractant" }, "value": "a" },
> { "attributeDefinition": { "name": "Département" }, "value": "a05e7e8d-a149-4c38-85e2-12cfc79674de" },
> { "attributeDefinition": { "name": "Loi applicable" }, "value": "915a2d5f-b6ba-4b2d-a01e-c63932a4c783" },
> { "attributeDefinition": { "name": "Forme juridique du cocontractant" }, "value": "6917969e-3e7f-4cc8-a027-4f54cfce32ee" },
> { "attributeDefinition": { "name": "Richtext" }, "value": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Sample text\"}]}]" },
> { "attributeDefinition": { "name": "Équipe" }, "value": "396c1bd9-7740-411f-bab4-854b995c2734" },
> { "attributeDefinition": { "name": "Montant" }, "value": "12000" },
> { "attributeDefinition": { "name": "Type de durée" }, "value": "FIXED_TERM" },
> { "attributeDefinition": { "name": "Date de fin" }, "value": "2026-01-31" },
> { "attributeDefinition": { "name": "Counterparty name" }, "value": "Adobe" },
> { "attributeDefinition": { "name": "Durée " }, "value": "1:month" },
> { "attributeDefinition": { "name": "Dénomination sociale de votre organisation" }, "value": "Acme Corp" },
> { "attributeDefinition": { "name": "Language of the document" }, "value": "fr" },
> { "attributeDefinition": { "name": "général Tomorro - Prochaine date de reconduction" }, "value": null },
> { "attributeDefinition": { "name": "Préavis" }, "value": null },
> { "attributeDefinition": { "name": "ID du projet" }, "value": "260600003" },
> { "attributeDefinition": { "name": "général Tomorro - Durée de reconduction" }, "value": null },
> { "attributeDefinition": { "name": "Type de reconduction" }, "value": "no" },
> { "attributeDefinition": { "name": "Date de signature du contrat-cadre" }, "value": null },
> { "attributeDefinition": { "name": "Start date" }, "value": "2026-01-01" }
> ],
> "attributes": {
> "0144e4a2-75ed-4893-954f-84686eb94ad6": { "value": "a", "name": "Adresse du cocontractant", "attributeDefinitionId": "0144e4a2-75ed-4893-954f-84686eb94ad6", "valueLabel": "a" },
> "12caf9df-d15d-4267-88b6-29b0ca9e4659": { "value": "a05e7e8d-a149-4c38-85e2-12cfc79674de", "name": "Département", "attributeDefinitionId": "12caf9df-d15d-4267-88b6-29b0ca9e4659", "valueLabel": "Comptabilité / Finances" },
> "14862943-7b0f-49ed-aa0a-33d1084fe1b6": { "value": "915a2d5f-b6ba-4b2d-a01e-c63932a4c783", "name": "Loi applicable", "attributeDefinitionId": "14862943-7b0f-49ed-aa0a-33d1084fe1b6", "valueLabel": "FR" },
> "32dc2fd1-6048-43aa-bbde-bb9e2b19f740": { "value": "6917969e-3e7f-4cc8-a027-4f54cfce32ee", "name": "Forme juridique du cocontractant", "attributeDefinitionId": "32dc2fd1-6048-43aa-bbde-bb9e2b19f740", "valueLabel": "EI" },
> "6dd82116-95a4-40a7-8100-4e9776d01d7f": { "value": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Sample text\"}]}]", "name": "Richtext", "attributeDefinitionId": "6dd82116-95a4-40a7-8100-4e9776d01d7f", "valueLabel": "[{\"type\":\"paragraph\",\"children\":[{\"text\":\"Sample text\"}]}]" },
> "96864b82-de02-43ad-8d68-1f60273d8aa5": { "value": "396c1bd9-7740-411f-bab4-854b995c2734", "name": "Équipe", "attributeDefinitionId": "96864b82-de02-43ad-8d68-1f60273d8aa5", "valueLabel": "Tech" },
> "d6cf7bb4-c897-498c-85ec-cf02251e6f9d": { "value": "12000", "name": "Montant", "attributeDefinitionId": "d6cf7bb4-c897-498c-85ec-cf02251e6f9d", "valueLabel": "12000" },
> "durationType": { "value": "FIXED_TERM", "name": "Type de durée", "attributeDefinitionId": "durationType", "valueLabel": "FIXED_TERM" },
> "endAt": { "value": "2026-01-31", "name": "Date de fin", "attributeDefinitionId": "endAt", "valueLabel": "2026-01-31" },
> "externalPartyName": { "value": "Adobe", "name": "Counterparty name", "attributeDefinitionId": "externalPartyName", "valueLabel": "Adobe" },
> "initialDuration": { "value": "1:month", "name": "Durée ", "attributeDefinitionId": "initialDuration", "valueLabel": "1:month" },
> "internalPartyName": { "value": "Acme Corp", "name": "Dénomination sociale de votre organisation", "attributeDefinitionId": "internalPartyName", "valueLabel": "Acme Corp" },
> "language": { "value": "fr", "name": "Language of the document", "attributeDefinitionId": "language", "valueLabel": "fr" },
> "nextRenewalDate": { "value": null, "name": "général Tomorro - Prochaine date de reconduction", "attributeDefinitionId": "nextRenewalDate", "valueLabel": null },
> "priorNotice": { "value": null, "name": "Préavis", "attributeDefinitionId": "priorNotice", "valueLabel": null },
> "projectIdentifier": { "value": "260600003", "name": "ID du projet", "attributeDefinitionId": "projectIdentifier", "valueLabel": "260600003" },
> "renewalDuration": { "value": null, "name": "général Tomorro - Durée de reconduction", "attributeDefinitionId": "renewalDuration", "valueLabel": null },
> "renewalType": { "value": "no", "name": "Type de reconduction", "attributeDefinitionId": "renewalType", "valueLabel": "no" },
> "signatureDate": { "value": null, "name": "Date de signature du contrat-cadre", "attributeDefinitionId": "signatureDate", "valueLabel": null },
> "startAt": { "value": "2026-01-01", "name": "Start date", "attributeDefinitionId": "startAt", "valueLabel": "2026-01-01" }
> }
> }
> }
> }
> ```
# Technical documentation
Source: https://alpha.developer.tomorro.com/webhooks/technical-documentation
Signature verification, delivery, retries, and security policy
## Security policy
Anyone who knows your endpoint URL could send it requests. To be sure an event genuinely comes
from Tomorro, **verify the signature on every request** before acting on it.
### Signing secret
Each webhook has its own secret, generated when you create it and shared only between Tomorro
and you. Tomorro uses it to sign every event; you use it to verify them. Treat it like a
password — never expose it client-side or commit it to source control.
### Signature header
Every event is sent with a signature header. Tomorro signs the event by computing an
HMAC-SHA256 over the timestamp and the raw request body, using your webhook secret:
```
sha256 = HMAC_SHA256(secret, timestamp + "." + rawBody)
```
The signature is delivered in the `Leeway-Signature` header, in the form `t=,sha256=`:
```http theme={null}
Leeway-Signature: t=1492774577000,sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```
* `t` — the timestamp the event was signed at, in **milliseconds** since the Unix epoch.
* `sha256` — the computed HMAC-SHA256 hash, hex-encoded.
> [!NOTE]
>
> For backward compatibility the same value is also sent in a `Leeway_Signature` header (with an
> underscore). Prefer the canonical `Leeway-Signature`; some proxies strip headers that contain
> underscores.
### Verifying the signature
To verify, recompute the HMAC over `timestamp + "." + rawBody` with your secret and compare it
to the `sha256` value from the header.
> [!WARNING]
>
> Compute the HMAC over the **raw request body bytes**, exactly as received — do not re-serialize
> the parsed JSON. Re-serializing can reorder keys or change whitespace and break the comparison.
```javascript Node.js theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* @param {string} header - the `Leeway-Signature` header value
* @param {string} secret - the webhook's signing secret
* @param {string} rawBody - the raw request body, exactly as received
*/
function isValidSignature(header, secret, rawBody) {
// Parse "t=,sha256="
const parts = Object.fromEntries(header.split(',').map((kv) => kv.trim().split('=')));
const { t: timestamp, sha256: received } = parts;
if (!timestamp || !received) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
// Constant-time comparison to avoid timing attacks
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
// Replay protection: reject events older than 5 minutes
const ageMs = Math.abs(Date.now() - Number(timestamp));
return ageMs < 5 * 60 * 1000;
}
```
> [!NOTE]
>
> Always use a **constant-time** comparison (e.g. `timingSafeEqual`) rather than `===` to avoid
> [timing attacks](https://en.wikipedia.org/wiki/Timing_attack).
### Replay protection
The signed timestamp also lets you reject **replayed** events: compare `t` to the current time
and discard anything outside a tolerance window you control (5 minutes is a reasonable default,
as shown above). This prevents an attacker from re-sending a previously valid, intercepted
request.
## Delivery & retries
### What counts as a successful delivery
When Tomorro sends an event, your endpoint must:
* respond with a **`2xx`** HTTP status code, and
* do so within the **3-second** timeout.
Any other status code, or a response that takes longer than the timeout, is treated as a
**failed** delivery.
> [!TIP]
>
> Acknowledge fast, process later. Return `2xx` as soon as you've received the event, then do
> any heavy work asynchronously. This keeps you comfortably under the 3-second timeout.
### Retry policy
If a delivery fails, Tomorro automatically retries it:
* Failed events are re-queued with a **5-minute delay** between attempts.
* Tomorro retries up to **10 times** for a given event.
* If every attempt fails, Tomorro stops retrying and **disables the webhook**. A disabled
webhook receives no further events until you re-enable it from the
[Webhooks settings](https://app.tomorro.com/settings/integrations?integration=webhook).
> [!NOTE]
>
> Because retries are spaced 5 minutes apart, your endpoint may receive the same event more than
> once. Make your handler **idempotent** — deduplicate using the `eventId` field so reprocessing
> a delivery has no side effects.
> [!NOTE]
>
> Exact timeout and retry values may change over time as we tune delivery.