> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pineyard.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Call transfer by webhook

> Let your own system choose, at call time, which person a caller is transferred to.

When a caller asks to speak to a person, an agent with call transfer turned on hands the call over. A **static**
transfer always goes to the same number. A **webhook** transfer asks your system first: Pineyard calls an HTTPS
endpoint you run, sends it the details of the call, and transfers to whatever number you send back. You can use this to
route by who is on shift, which branch the caller belongs to, the campaign, or the contact's account owner.

<Note>
  Webhook transfer may not be available for your workspace yet. If it isn't, the dashboard doesn't show the
  **Webhook** option, and the API refuses `transferMode: "WEBHOOK"` with `400 transfer_webhook_unavailable`. Contact
  Pineyard to enable it.
</Note>

## How it works

1. During a call, the caller asks for a human.
2. While the caller is on hold, Pineyard sends a signed `POST` to your URL with details of the call and contact, and the reason for the transfer.
3. Your endpoint replies `2xx` with `{"to_number": "+919876543210"}` before the timeout runs out.
4. Pineyard transfers the call to that number.

If your endpoint doesn't send back a usable number in time, the call goes to the agent's **fallback number**. If the
agent has no fallback number, the AI apologises and the conversation continues. There are no retries, because the
caller is on hold while Pineyard waits.

## Turn it on for an agent

### In the dashboard

Open the agent, go to **Call transfer**, turn transfer on and choose **Webhook**. Then set:

* **Webhook URL**: your endpoint.
* **Signing secret** (optional, recommended): Pineyard uses it to sign every request. You can set, replace or
  remove it, but once saved it is never shown again.
* **Timeout**: how long Pineyard waits for your answer.
* **Fallback number** (optional): used when your endpoint has no answer. In webhook mode, this replaces the static
  transfer number.

### With the API

The same settings are fields on [`POST /agents`](/api-reference/create-a-new-agent) and
[`PATCH /agents/{id}`](/api-reference/update-an-agent) (scope `agents.edit`):

| Field | Meaning |
| - | - |
| `transferEnabled` | `true` to let the agent transfer calls. |
| `transferMode` | `STATIC` (default) transfers to `transferNumber`. `WEBHOOK` asks `transferWebhookUrl` for a number on every transfer. |
| `transferWebhookUrl` | Your endpoint. `https://`, port 443, no credentials in the URL, up to 2048 characters. `null` removes it. |
| `transferWebhookSecret` | **Write-only** signing secret, up to 1024 characters. A string sets it, `null` or `""` removes it, and leaving it out keeps the current secret. |
| `transferWebhookTimeoutMs` | How long Pineyard waits for your answer, from `500` to `4000` milliseconds. Default `3000`. |
| `transferNumber` | In `WEBHOOK` mode, the optional fallback number (E.164). In `STATIC` mode, the transfer number. |

```bash theme={null}
curl -X PATCH https://api.pineyard.ai/agents/$AGENT_ID \
  -H "Authorization: Bearer $PINEYARD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "transferEnabled": true,
    "transferMode": "WEBHOOK",
    "transferWebhookUrl": "https://example.com/pineyard/transfer",
    "transferWebhookSecret": "a-long-random-string-you-keep-private",
    "transferWebhookTimeoutMs": 3000,
    "transferNumber": "+919800000000"
  }'
```

Agent responses include `transferMode`, `transferWebhookUrl`, `transferWebhookTimeoutMs` and
`hasTransferWebhookSecret`. The secret itself is never returned.

<Warning>
  **Only a person signed in to the dashboard can set the URL and secret.** A request made with an API key that sets
  or changes `transferWebhookUrl` or `transferWebhookSecret` is refused with `403 transfer_webhook_requires_user`.
  Your URL receives caller and contact details during live calls, so a person must choose it. API keys can still read
  these fields and switch `transferMode` between `STATIC` and `WEBHOOK`. Sending back the URL the agent already has
  doesn't count as a change.
</Warning>

Validation errors:

| Status | `error` | Cause |
| - | - | - |
| 400 | `transfer_webhook_url_required` | Transfer is on in `WEBHOOK` mode, but the agent has no `transferWebhookUrl`. |
| 400 | `invalid_transfer_webhook_url` | The URL isn't `https://` on port 443, contains credentials, or is too long. `reason` says which. |
| 400 | `invalid_transfer_webhook_secret` | The secret is only whitespace, or longer than 1024 characters. |
| 400 | `transfer_webhook_unavailable` | Webhook transfer isn't enabled for your workspace. |
| 403 | `transfer_webhook_requires_user` | An API key tried to set or change the URL or secret. |

Duplicating an agent copies its transfer settings, including the URL and the secret.

## The request

```http theme={null}
POST /pineyard/transfer HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Pineyard-Transfer/1
X-Pineyard-Event: call.transfer.resolve
X-Pineyard-Timestamp: 1792137600000
X-Pineyard-Signature: v1=5d0c6f2b8e0a4f6c9b3e1d7a2c4f8e6b0a9d3c5e7f1b2a4c6e8d0f2a4b6c8e0d

{
  "event": "call.transfer.resolve",
  "version": "2026-10-16",
  "call_id": "00000000-0000-4000-8000-0000000000b1",
  "agent_id": "00000000-0000-4000-8000-0000000000a7",
  "workspace_id": "00000000-0000-4000-8000-0000000000f1",
  "direction": "outbound",
  "from_number": "+918000000000",
  "to_number": "+919812345678",
  "campaign_id": "00000000-0000-4000-8000-0000000000c1",
  "contact": {
    "id": "00000000-0000-4000-8000-0000000000d1",
    "name": "Jordan Example",
    "phone": "+919812345678"
  },
  "reason": "Caller wants to discuss a refund with a person"
}
```

| Header | Value |
| - | - |
| `X-Pineyard-Event` | Always `call.transfer.resolve`. |
| `X-Pineyard-Timestamp` | When the request was sent, as Unix time in **milliseconds**. |
| `X-Pineyard-Signature` | `v1=<hex>`, sent only if the agent has a signing secret. See [Verify the signature](#verify-the-signature). |
| `User-Agent` | `Pineyard-Transfer/1` |

| Field | Meaning |
| - | - |
| `event` | `call.transfer.resolve`. |
| `version` | Payload version, currently `2026-10-16`. Fields may be added in the same version, so ignore any field you don't recognise. |
| `call_id` | The Pineyard call ID, usable with [`GET /calls/{id}`](/api-reference/get-a-call-by-id). Can be `null`, for example on an inbound call Pineyard hasn't recorded yet. |
| `agent_id`, `workspace_id` | The agent taking the call and its workspace. |
| `direction` | `inbound`, `outbound`, or `null` if unknown. |
| `from_number`, `to_number` | The call's caller and called numbers, normalised to E.164, or `null` if unknown. On an outbound call `to_number` is the person being called. On an inbound call `from_number` is the caller. |
| `campaign_id` | The campaign the call belongs to, or `null`. |
| `contact` | `{ id, name, phone }` of the Pineyard contact on the call, or `null`. `name` can be `null`, and is cut to 200 characters. |
| `reason` | A short, plain-language reason for the transfer, written by the agent, up to 500 characters. Can be `null`. |

## The response

Reply with any **`2xx`** status and a JSON body containing the number to transfer to:

```json theme={null}
{ "to_number": "+919876543210" }
```

* `to_number` must be **one** number in E.164 format **with a leading `+`**. Pineyard doesn't guess a country code.
  Don't send a list or alternatives (such as `"+919876543210, +919876543211"` or `"+91... or +91..."`).
* The answer must arrive within the agent's `transferWebhookTimeoutMs`. That budget also covers connecting to your
  server and Pineyard's own processing of the call, so answer **well inside** it. Keep the response body small
  (under 16 KB).
* Redirects aren't followed.

Pineyard treats any of these as **no one available**:

* a non-`2xx` status, a redirect, a timeout or a connection error
* a body that isn't JSON, or one with no `to_number`, a `null` or empty `to_number`, or a `to_number` that isn't a valid number with a `+`
* a `to_number` that contains more than one number, for example separated by `,`, `/`, `;`, `|`, `or` or `and`
* a `to_number` that is one of the two parties already on the call (the caller or the called number)

In those cases the call goes to the agent's fallback `transferNumber` if one is set. Otherwise the AI tells the caller
no one is available and keeps helping them. Pineyard never retries a transfer request.

If your workspace is suspended, Pineyard doesn't call your endpoint at all. Transfers go straight to the fallback
number, as if no one were available.

To deliberately send a caller to the fallback number (for example, outside office hours), reply
`200 {"to_number": null}` or any non-`2xx` status.

## Verify the signature

When the agent has a signing secret, `X-Pineyard-Signature` is `v1=` followed by the lowercase hex
HMAC-SHA256, keyed with your secret, of:

```text theme={null}
{X-Pineyard-Timestamp}.{raw request body}
```

Compute it over the **raw bytes** of the body, before parsing the JSON. Compare in constant time, and reject
requests whose timestamp is more than 5 minutes from your clock, to block replays. The timestamp is in
**milliseconds**.

<CodeGroup>
  ```javascript Node (Express) theme={null}
  import express from 'express'
  import { createHmac, timingSafeEqual } from 'node:crypto'

  const SECRET = process.env.PINEYARD_TRANSFER_SECRET
  const TOLERANCE_MS = 5 * 60 * 1000
  const app = express()

  app.post('/pineyard/transfer', express.raw({ type: 'application/json' }), (req, res) => {
    const timestamp = req.get('X-Pineyard-Timestamp') ?? ''
    const signature = (req.get('X-Pineyard-Signature') ?? '').replace(/^v1=/, '')

    if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > TOLERANCE_MS) {
      return res.status(401).end()
    }

    const expected = createHmac('sha256', SECRET)
      .update(`${timestamp}.`)
      .update(req.body) // raw Buffer
      .digest('hex')
    const ok = signature.length === expected.length
      && timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    if (!ok) return res.status(401).end()

    const event = JSON.parse(req.body.toString('utf8'))
    const toNumber = pickAgentFor(event) // your routing: on-shift rota, account owner, branch...

    // Answer fast: the caller is on hold. null sends the call to the fallback number.
    res.json({ to_number: toNumber ?? null })
  })
  ```

  ```python Python (Flask) theme={null}
  import hashlib, hmac, os, time
  from flask import Flask, request, abort, jsonify

  SECRET = os.environ["PINEYARD_TRANSFER_SECRET"].encode()
  TOLERANCE_MS = 5 * 60 * 1000
  app = Flask(__name__)

  @app.post("/pineyard/transfer")
  def pineyard_transfer():
      timestamp = request.headers.get("X-Pineyard-Timestamp", "")
      signature = request.headers.get("X-Pineyard-Signature", "").removeprefix("v1=")

      if not timestamp.isdigit() or abs(time.time() * 1000 - int(timestamp)) > TOLERANCE_MS:
          abort(401)

      raw = request.get_data()  # raw bytes, before JSON parsing
      expected = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(signature, expected):
          abort(401)

      event = request.get_json()
      to_number = pick_agent_for(event)  # your routing: on-shift rota, account owner, branch...

      # Answer fast: the caller is on hold. None sends the call to the fallback number.
      return jsonify(to_number=to_number)
  ```
</CodeGroup>

If you don't set a secret, requests aren't signed and `X-Pineyard-Signature` isn't sent. Set a secret for anything
beyond a quick test.

## Requirements

* The URL must be `https://` on port **443**, with no username or password in it.
* It must be reachable from the public internet. Pineyard won't connect to private, loopback or link-local addresses,
  including through DNS. A URL that resolves to one of these is treated as no one available.
* Your endpoint must answer well within the timeout (500 to 4000 ms, 3000 ms by default), which also covers connecting
  and Pineyard's own processing. Look up your routing data
  ahead of time or keep it cached, and don't call slow services before replying.

## Testing

* **Set a fallback number** before going live, so a slow or failing endpoint still reaches a person.
* Use a [test call](/api-reference/initiate-a-sandbox-call-from-this-agent-to-a-phone-number) or a web session from the dashboard, and ask the agent to
  speak to a person.
* Log the raw body and headers of the first few requests, and check that your signature check accepts them before
  you start rejecting unsigned or invalid requests.
* To test the fallback, temporarily return `503` or `{"to_number": null}`: the call should go to the fallback number,
  or, without one, the agent should apologise and carry on.
* A tunnelling tool that gives you a public `https://` URL works for local development. Make sure it answers well
  within the timeout.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.