> ## 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.

# Receive results

> Get one signed webhook per lead when its result is final, or poll for it.

Leads pushed with [`POST /v1/campaigns/{campaignId}/leads`](/guides/run-a-campaign#push-leads-with-the-public-api)
produce exactly **one result** each, once Pineyard has finished with the lead: automatic re-dials, callbacks and
call analysis are all done. You can receive results by webhook, poll for them, or both.

<Note>
  Results are available for **API campaigns** in workspaces where results are enabled. Otherwise the result
  endpoints return `403 api_results_not_enabled`. Contact Pineyard to enable them.
</Note>

## The result

```json theme={null}
{
  "id": "00000000-0000-4000-8000-0000000000e1",
  "type": "api.lead.result",
  "apiVersion": "1.0",
  "createdAt": "2026-10-14T10:22:31.512Z",
  "data": {
    "leadId": "00000000-0000-4000-8000-0000000000a1",
    "campaignId": "00000000-0000-4000-8000-0000000000c1",
    "idempotencyKey": "crm-lead-48213",
    "lead": { "phone": "+15555550100", "name": "Jordan Example", "fields": { "first_name": "Jordan" } },
    "status": "completed",
    "disposition": "INTERESTED",
    "attempts": 2,
    "finalCall": {
      "id": "00000000-0000-4000-8000-0000000000b1",
      "status": "COMPLETED",
      "outcome": "GOAL_MET",
      "providerDisposition": "completed",
      "attemptNumber": 2,
      "answeredAt": "2026-10-14T10:15:02.000Z",
      "endedAt": "2026-10-14T10:15:44.000Z",
      "durationMs": 42000
    },
    "analysis": {
      "status": "DONE",
      "evaluation": { "interest_level": "hot" },
      "extraction": { "preferred_time": "5 pm" }
    }
  }
}
```

| Field | Meaning |
| - | - |
| `id` | Result ID. Same value on every delivery of this result: use it to de-duplicate. |
| `data.status` | `completed`, `failed` or `skipped` (for example a Do Not Call number, or a campaign stopped before the lead was called). |
| `data.disposition` | The lead's final disposition, such as `INTERESTED`, `NOT_INTERESTED`, `CALLBACK_SCHEDULED`, `UNREACHABLE` or `OPTED_OUT`. |
| `data.attempts` | Number of call attempts made. |
| `data.finalCall` | The last call placed, or `null` if no call was placed. `durationMs` is in milliseconds. |
| `data.analysis` | Evaluation and extracted fields from the call. Only the fields configured for the campaign are included; a configured field with no value is `null`. `status` is `SKIPPED` when there was no call. |

Results are **immutable**: once created, a result never changes, even if the lead is called again by hand later.
Results are kept for **90 days**.

Results don't include the transcript or recording. Use `data.finalCall.id` with
[`GET /calls/{id}`](/guides/recordings-and-transcripts).

## Webhooks

### Set up an endpoint

Webhook endpoints are created by a workspace admin in the Pineyard dashboard. API keys can list and delete
endpoints but cannot create, change or verify them.

* The URL must be `https://` on port 443, with no credentials in it. Private network addresses are refused.
* An endpoint can receive results from every API campaign, or only from chosen campaigns.
* When the endpoint is created you get its **signing secret** (`pyr_whsec_…`). It is shown once.
* The dashboard **verifies** the endpoint by sending a signed test event (`type: "endpoint.test"`). Your endpoint must
  reply `2xx`. Results are delivered only for leads pushed **after** the endpoint was first verified. Changing the URL
  requires verifying again.
* Up to 10 endpoints per workspace.

### Request format

Pineyard sends a `POST` with the result as the JSON body and these headers:

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `Pineyard-Webhooks/1.0` |
| `Pineyard-Event-Id` | The result `id`. The same on every retry and redelivery. |
| `Pineyard-Delivery-Id` | ID of this endpoint's delivery. The same across retries of it. |
| `Pineyard-Timestamp` | Unix time in seconds when this attempt was sent. |
| `Pineyard-Signature` | `v1=<hex>`. During a secret rotation, two values separated by a comma: `v1=<new>,v1=<old>`. |

### Verify the signature

The signature is an HMAC-SHA256, keyed with your endpoint secret, of:

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

encoded as lowercase hex. Compute it over the **raw bytes** of the body, before any JSON parsing. Accept the request
if **any** `v1=` value matches, and reject timestamps more than 5 minutes old to block replays.

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

  const SECRET = process.env.PINEYARD_WEBHOOK_SECRET // pyr_whsec_...
  const app = express()

  app.post('/webhooks/pineyard', express.raw({ type: 'application/json' }), (req, res) => {
    const eventId = req.get('Pineyard-Event-Id')
    const timestamp = req.get('Pineyard-Timestamp')
    const signatures = (req.get('Pineyard-Signature') ?? '').split(',').map((s) => s.trim().replace(/^v1=/, ''))

    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.status(400).end()

    const expected = createHmac('sha256', SECRET)
      .update(`${eventId}.${timestamp}.${req.body.toString('utf8')}`)
      .digest('hex')
    const ok = signatures.some((sig) =>
      sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
    if (!ok) return res.status(401).end()

    const event = JSON.parse(req.body.toString('utf8'))
    // Store it durably (dedupe on event.id), then acknowledge.
    res.status(204).end()
  })
  ```

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

  SECRET = os.environ["PINEYARD_WEBHOOK_SECRET"].encode()  # pyr_whsec_...
  app = Flask(__name__)

  @app.post("/webhooks/pineyard")
  def pineyard_webhook():
      event_id = request.headers.get("Pineyard-Event-Id", "")
      timestamp = request.headers.get("Pineyard-Timestamp", "0")
      signatures = [s.strip().removeprefix("v1=") for s in request.headers.get("Pineyard-Signature", "").split(",")]

      if abs(time.time() - int(timestamp)) > 300:
          abort(400)

      raw = request.get_data()  # raw bytes, before JSON parsing
      expected = hmac.new(SECRET, f"{event_id}.{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
      if not any(hmac.compare_digest(sig, expected) for sig in signatures):
          abort(401)

      event = request.get_json()
      # Store it durably (dedupe on event["id"]), then acknowledge.
      return "", 204
  ```
</CodeGroup>

### Responding, retries and duplicates

* Reply with any **`2xx`** once you have stored the event. Do slow work afterwards; requests time out after **10 seconds**.
* These are retried: timeouts, network errors, `408`, `425`, `429` and `5xx`. Retries come after 30 s, 2 min,
  10 min, 1 h, 4 h, 12 h and 24 h: 8 attempts over about 41 hours, after which the delivery is marked dead.
* A `Retry-After` header on a `429` sets the wait before the next attempt (between 1 second and 24 hours).
* Any other response, including redirects (`3xx`) and other `4xx` codes, fails the delivery immediately. Redirects
  are not followed.
* Delivery is **at least once** and **not ordered**. De-duplicate on `Pineyard-Event-Id`.
* Dead or failed deliveries can be redelivered from the dashboard. A redelivery sends exactly the same body.
* Paused endpoints keep collecting results and send them when resumed.

### Rotating the secret

When a workspace admin rotates the secret in the dashboard, both the new and the previous secret sign every request
for **24 hours**. Deploy the new secret within that window.

### Delivery history

API keys with `result_webhooks.view` can read endpoints and delivery attempts:
`GET /result-webhooks/endpoints`, `GET /result-webhooks/deliveries` (cursor-paginated) and
`GET /result-webhooks/deliveries/{id}`.

## Polling

| Endpoint | Use |
| - | - |
| `GET /v1/leads/{leadId}/result` | One lead. `202 {"status": "pending"}` with `Retry-After: 15` until the result is ready, then `200` with the result. `410 result_expired` after 90 days. |
| `GET /v1/results/{resultId}` | A result by its ID. |
| `GET /v1/results` | All results, newest first, [cursor-paginated](/concepts/pagination). Filter with `campaignId`, `idempotencyKey` or `createdAfter` (exclusive). |

Scope: `campaigns.view`.

To catch up after downtime, page through `GET /v1/results?createdAfter=<last result createdAt you stored>`.


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