# Make your first API request

Connect a hosted Terramine workspace to an external CRM or ERP using a service key and signed webhooks. This guide covers two workflows. Your CRM can request a draft sales order in Terramine ERP and receive a notification when the quotation is sent. Terramine CRM can notify your automation when a deal moves, then show the order number and link from your ERP on the deal card.

Building an AI agent? The [agent integration guide](agents.md) maps tool calls to scopes and walks through draft creation, deal linking, event verification and recovery after a timeout.

Download the [OpenAPI contract](openapi.json) for the supported hosted API operations. The [n8n workflow](workflow.json) provides a manual CRM request you can run before connecting a live CRM trigger.

For an own deployment, use the [own-deployment API reference](https://docs.terraminedesk.ai/docs/reference.html?deployment=own) and [OpenAPI contract](https://docs.terraminedesk.ai/docs/openapi-own.json). They also document customer creation, suppliers, inventory, documents and accounting. Availability and request behavior differ by deployment; this quickstart uses the hosted contract.

## Try the customer example kit

Download the [customer integration kit](https://docs.terraminedesk.ai/docs/downloads/terramine-integration-kit.zip). It includes a Python client, a local simulation, webhook verification, the OpenAPI contract and the n8n example. Python 3.10 or newer is the only dependency; private repository access is not required. Start with the included README and run the local demo before connecting your own workspace. The demo uses synthetic records and does not create orders in Terramine.

## Before you start

Have these ready:

- Your workspace's HTTPS address, such as `https://YOUR-WORKSPACE.YOUR-REGION.terraminedesk.ai`. Copy the complete address from **Admin → Integrations**, including your workspace’s region. `YOUR-WORKSPACE` and `YOUR-REGION` are placeholders; neither is a default.
- The current workspace owner, with an administrator role, to create credentials and subscriptions.
- The relevant module enabled: ERP for customers and draft orders; CRM for deals and external order links. For draft orders, set up the selling entity in **ERP → Reference → Our companies** and an existing customer.
- A secret store for the service key. For events, also provide a public HTTPS receiver on port 443 and a place to store its signing secret. Ask your workspace administrator to confirm that outgoing webhook delivery is available.

Fetch `GET /api/v1/openapi.json` from that workspace before building against it. This unauthenticated endpoint identifies the deployment/release contract. It does not confirm that a module is enabled, entitled, or ready, or that your key can use an operation; the authenticated business request checks those conditions. If it returns 404, check the address and workspace rollout with the owner.

## Create a service key

1. As the workspace owner, open **Admin → Integrations → Create a service key**.
2. Name the automation and select `customers:read` for your first read. For the quotation workflow below, also select `orders:write`, `orders:read` and `documents:read`.
3. Acknowledge access to **all workspace records of the selected object types**, then create the key. Use a separate named key for each automation.
4. Copy the one-time secret to the automation's credential store. Use **Look up customers** on the same page to check the first authenticated read.

For the curl examples below, set `DESK_URL` to your workspace's HTTPS origin and inject the key as `DESK_KEY` from the secret store. Send `Authorization: Bearer <key>` on every business API request. Keep authenticated requests on that origin and disable redirects in your HTTP client.

A successful lookup returns a JSON page with `data` and `next_updated_since`. Rotating a key invalidates its old secret immediately while preserving subscriptions and request receipts. Revoke the key when retiring the automation.

## Make your first request

Inject `DESK_KEY` from your secret store into the shell environment, then set `DESK_URL` to your complete workspace URL and run this read-only request:

```sh
DESK_URL='https://YOUR-WORKSPACE.YOUR-REGION.terraminedesk.ai'
: "${DESK_KEY:?Inject your service key from your secret store first}"
curl --fail-with-body --get \
  -H "Authorization: Bearer $DESK_KEY" \
  --data-urlencode 'stable_cursor=true' \
  "$DESK_URL/api/v1/customers"
```

Expect HTTP 200 and a JSON page containing `data` and `next_updated_since`. An empty workspace returns this valid response:

```json
{"data":[],"next_updated_since":null}
```

If customer records exist, `data` contains those records. A null cursor means there is no next page. For another page, keep `stable_cursor=true` and your filters fixed, and pass the entire non-null cursor as URL-encoded `updated_since`. For errors, use [Troubleshoot a request](#troubleshoot-a-request).

## Register a webhook receiver

Webhook setup is optional for your first read. To receive `quote.sent`, use a service key with `documents:read`; for `crm.deal.stage_changed`, use `crm:read`.

Under **Receive signed events**, select the service key, event and receiver's HTTPS URL, then create the subscription. Save its one-time signing secret on the receiver; each subscription has its own secret. To change the destination, create a new subscription and disable the old one.

Use **Send synthetic test** to check delivery and signing. It sends `integration.test`; verify it, save its event ID, and acknowledge it without taking a business action. Refresh **Delivery history** to see the result. A successful test checks the receiver connection; complete the business workflow below to verify order creation or linking.

## Run the n8n workflow on your tenant

Import the [n8n example](workflow.json). In **Simulated CRM request**, enter your tenant URL and records:

| Field | Value |
| --- | --- |
| `base_url` | `https://YOUR-WORKSPACE.YOUR-REGION.terraminedesk.ai` |
| `customer_name` | The exact name of an existing customer in your workspace |
| `crm_deal_id` | The originating CRM deal reference |
| `quote_request_id` | A unique reference for this quotation request; keep it unchanged for retries |
| `currency` | The currency for this draft, for example `USD` |
| `lines_json` | The requested parts and quantities, for example `[{"pn":"TEST-123","qty":2}]` |

Replace the example tenant URL with your workspace's actual HTTPS address. Create a Header Auth credential containing `Authorization: Bearer <workspace service key>` and select it on both HTTP nodes. Running the workflow creates a draft in that tenant. Run the same input twice with the same credential and `quote_request_id`: both runs should return the same draft ID and order number. For a new quotation request, including another request for the same deal, use a new `quote_request_id`. Keep the saved payload unchanged during retries.

The workflow stops if customer lookup is incomplete or does not find exactly one name match. Resolve the customer in Terramine before running again. When the draft is created, open it in **ERP → Customer orders** for pricing and sending.

## Request a Terramine draft from your CRM

1. When a CRM deal reaches your quote-request stage, look up its customer. Match an existing Terramine customer ID, or present ambiguous name matches to a person. A customer can have several deals with different quote requests.
2. Send the draft with the customer's ID, the deal ID in `external_ref`, and an `Idempotency-Key` stable for that quote request. Keep the same key, credential, and body through retries. A reused key with different input returns 409. For a genuinely new quote request on the same deal, choose a new request ID.
3. Save the returned order ID on the deal. When Terramine sends `quote.sent`, its `data.order_id` identifies the order. Read the order and its `external_ref` to recover the originating deal even if your first request timed out.

```sh
curl --fail-with-body -H "Authorization: Bearer $DESK_KEY" \
  "$DESK_URL/api/v1/customers?q=Example&stable_cursor=true"
curl --fail-with-body -H "Authorization: Bearer $DESK_KEY" \
  -H 'Content-Type: application/json' -H 'Idempotency-Key: quote-request-4721' \
  -d '{"customer_id":"REPLACE_WITH_MATCHED_ID","external_ref":"deal-307","lines":[{"pn":"TEST-123","qty":2}]}' \
  "$DESK_URL/api/v1/orders"
```

The response contains the draft's `id` and `no`; creation and successful idempotent retries return 201. A draft is ready for the salesperson to price and send. For lists, request `stable_cursor=true` and pass the entire returned `next_updated_since` string, URL encoded, as the next request's `updated_since`. Keep filters fixed. Stop when the cursor is null.

## Link an external ERP order to a Terramine deal

Create a separate service key with `crm:read` and `crm:write`. Register your receiver for `crm.deal.stage_changed` with this key. In the receiver, compare `data.to_stage` with the customer's configured stage, for example `negotiation`. Fetch the deal with `GET /api/v1/crm/deals/{deal_id}` to check its current stage and company. A deal can leave and re-enter a stage.

Use a durable external ERP request reference derived from the workspace and Terramine deal ID. Reuse that reference for repeated events, including new events for the same deal. If the ERP supports idempotent creation, use its idempotency key. Otherwise reconcile by that unique external reference before retrying a timed-out create. Once the ERP confirms its order, attach its number and HTTPS link to Terramine. Inject this separate service key as `CRM_KEY` from your secret store. Set `DEAL_ID` to the Terramine deal ID from the verified event, and use the same `DESK_URL` as above.

```sh
curl --fail-with-body -X PUT -H "Authorization: Bearer $CRM_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"system":"Customer ERP","order_number":"SO-234","url":"https://erp.example/orders/234"}' \
  "$DESK_URL/api/v1/crm/deals/$DEAL_ID/external-order"
```

Repeating the same association succeeds. A different existing association returns 409 so the integrator can reconcile it. The salesperson can open the linked order from the deal.

## Receive, verify, and replay

Read `X-Desk-Signature: t=<timestamp>,v1=<hex digest>` from the request headers. Using the subscription signing secret, verify the HMAC over the timestamp, a period, and the **exact raw HTTP body** before parsing. This Python example returns the signed envelope. Use a synchronized clock.

```python
import hashlib
import hmac
import json
import time

def verified_event(raw: bytes, signature: str, secret: str):
    fields = dict(pair.split("=", 1) for pair in signature.split(","))
    timestamp = int(fields["t"])
    if abs(time.time() - timestamp) > 300:
        raise ValueError("expired webhook")
    expected = hmac.new(secret.encode(), str(timestamp).encode() + b"." + raw,
                        hashlib.sha256).hexdigest()
    if not hmac.compare_digest(fields["v1"], expected):
        raise ValueError("invalid webhook signature")
    return json.loads(raw)
```

Reject malformed or invalid messages. After verification, insert the signed event ID into a durable inbox with a unique constraint for this integration. Acknowledge with 2xx after that insert commits, including when the same event is already present. A worker should perform the CRM or ERP action with a stable business request ID. A crash between remote creation and recording success requires reconciliation with the receiving system.

Respond promptly: webhook delivery uses a five-second HTTP timeout and does not follow redirects. A non-2xx response is recorded as rejected; it does not schedule an automatic business retry.

Open **Admin → Integrations → Delivery history** to inspect the last 100 deliveries. Use **Retry delivery** for a rejected, expired or confirmed-unsent delivery. Replays retain event and delivery IDs. If delivery is unknown, check the receiver's inbox by event ID before deciding how to recover. A 2xx records receipt by the receiver. The business result is the order or deal association recorded by the receiving application.

## Troubleshoot a request

| Result | Next step |
| --- | --- |
| 400 or 422 | Correct the request fields or query parameter types against the contract. The response may contain `error` or validation `detail`. |
| 401 | Check the workspace URL and whether the key is valid, expired, rotated or revoked. |
| 403 | Ask the owner to check the key's scope and the module's access and entitlement. |
| 404 | Check the record reference, workspace address and route availability. |
| 409 | Reconcile the saved request or existing external order link before another write. Do not create a new key to bypass a conflict. |
| 413 | Reduce the request body below the hosted 128 KiB limit. Drafts accept 1–300 lines. |
| 429 | Honor `Retry-After` when supplied, then retry with backoff. |
| Timeout or temporary 5xx | The write outcome may be unknown. Retry with the same service key, request key and saved payload; reconcile before starting a new request. |

List pages default to 50 records and accept up to 200. Use `stable_cursor=true` and keep filters fixed while paging; treat `next_updated_since` as opaque and stop when it is null. A workspace supports up to 20 active webhook subscriptions. Synthetic tests are limited to one per minute for each subscription.
