# Inbound webhooks

> Give any app a URL that turns what it sends into Couplr customers or jobs. Covers setup, sender authentication, field mapping, acknowledgments, duplicates, retries and troubleshooting.

- Canonical URL: https://couplrfsm.com/developers/inbound-webhooks
- Markdown: https://couplrfsm.com/developers/inbound-webhooks.md
- API version: v1 (base URL https://couplrfsm.com/api/v1)
- OpenAPI specification: https://couplrfsm.com/developers/openapi.yaml
- All documentation pages: https://couplrfsm.com/llms.txt

> **Flip:** A webhook is the easy way in when the other app can't call an API. You give it a URL, it sends me whatever it has, and I turn that into a customer or a job using a mapping you approve. No code needed on your side.

An inbound webhook is a URL that belongs to one Couplr company. Another system sends data to it with an HTTP `POST`. Couplr stores what arrived, then uses a field mapping you set up to create or update a customer, or to create a job.

Use one when the other system can send data to a URL but cannot call an API, for example a website form, an ad lead form routed through Zapier, or a scheduling tool.

## Set up an endpoint

A company admin does this on the **Integrations** page (`https://couplrfsm.com/dashboard/integrations`), in the "Receive data" section.

1. Choose **New webhook** and give it a name, such as "Website contact form".
2. Leave **Require a signature (advanced)** off unless the sending app can sign its requests. See [Sender authentication](#sender-authentication).
3. Copy the webhook URL. It looks like `https://couplrfsm.com/hooks/YOUR_WEBHOOK_TOKEN`. If you turned signatures on, also copy the signing secret. It is shown once.
4. Get a sample: have the sending app send one real submission to the URL, or paste an example payload.
5. Review the field mapping Couplr proposes, adjust it and save. See [Field mapping](#field-mapping).
6. Turn the webhook on. Its status changes from "Waiting for a sample" to "Active".

> **Flip:** In the app you can ask me to do step 1 for you: "make a webhook for my website form". I'll create it and give you the URL.

The URL contains a long random token that identifies the endpoint and the company. Treat the whole URL as a secret. The company is always taken from the endpoint. A company or tenant id inside the payload has no effect.

## Send data

Send a `POST` to the webhook URL with one of:

- A JSON object: `Content-Type: application/json`.
- A JSON array of objects. Each element is treated as a separate event. At most 25 per request.
- A form post: `application/x-www-form-urlencoded`, including nested keys such as `contact[email]`, or `multipart/form-data` fields. Uploaded files are ignored. A signed webhook (`hmac` or `standard_webhooks`) can't verify `multipart/form-data` and answers `415`; send JSON or a urlencoded form instead.

```bash
curl -X POST "https://couplrfsm.com/hooks/YOUR_WEBHOOK_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Event-Id: lead-8841" \
  -d '{"name": "Dana Whitfield", "email": "dana@example.com", "phone": "555-201-8890"}'
```

Optional headers:

| Header | Purpose |
| --- | --- |
| `X-Event-Id` | Your unique id for this event, up to 191 characters. Used to recognize duplicates. `Idempotency-Key` is accepted as another name for it. |
| `X-Event-Type` | A label for the kind of event, up to 100 characters. Stored with the event. |

Limits:

| Limit | Value | Response when exceeded |
| --- | --- | --- |
| Request body size | 256 KB | `413` |
| Events in one JSON array | 25 | `413` |
| Requests per endpoint | 120 a minute | `429` |

Couplr never fetches URLs, downloads attachments or runs anything found in a payload. Links and file references arrive as plain text.

## Sender authentication

> **Flip:** Most apps can't sign what they send, so most webhooks use the secret URL on its own. That's fine for leads, as long as you treat the URL like a password. If your sender can sign, turn signatures on and I'll refuse anything that isn't signed correctly.

An endpoint works in one of two ways, chosen when it is created.

**Secret URL (default).** The URL is the only credential. Anyone who has the URL can send data to it. This is the only choice for senders that cannot add headers, and it is what most form tools and automation steps use. It proves the sender knows the URL. It does not prove who sent the request or that the body was not changed in transit by something that also knows the URL. If the URL may have leaked, delete the webhook and create a new one.

**Signature required.** With "Require a signature" on, every request must carry an `X-Hvacpro-Signature` header holding the hex HMAC-SHA256 of the request, computed with the signing secret. A request with a missing or wrong signature gets `401` and is not stored or processed. There is no fallback to the secret URL.

Sign one of two ways:

1. Recommended. Also send `X-Hvacpro-Timestamp` with the current Unix time in seconds, and sign the string `TIMESTAMP.BODY`, where `BODY` is the exact bytes you send. The timestamp must be within 5 minutes of the current time, so a captured request cannot be replayed later.
2. Without the timestamp header, sign the exact body bytes alone.

```bash
BODY='{"name":"Dana Whitfield","email":"dana@example.com"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "YOUR_SIGNING_SECRET" -hex | sed 's/^.* //')
curl -X POST "https://couplrfsm.com/hooks/YOUR_WEBHOOK_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Hvacpro-Timestamp: $TS" \
  -H "X-Hvacpro-Signature: $SIG" \
  -d "$BODY"
```

The signature may be prefixed with `sha256=`. Sign the bytes you actually send: re-serializing the JSON after signing changes the bytes and the signature will not match.

## Test events

- While a webhook is "Waiting for a sample", the first request it receives is saved as the sample used to build the mapping. Nothing is created in Couplr.
- You can paste an example payload instead of sending one.
- Requests received before the webhook is turned on are stored with status "Skipped" and create nothing. After you turn the webhook on you can replay a skipped event from its activity list.

There is no separate test mode in the Integrations screens yet that previews what a live event would create. To try a mapping safely, send a test submission with a contact you recognize, check the result in the activity list, then delete the test customer.

## Field mapping

> **Flip:** Send me one real example and I'll read it and suggest which of its fields is the name, the email, the phone and so on. You check my work and change anything I got wrong before it goes live.

A mapping says which record to create and where each of its fields comes from. Couplr proposes one from your sample. You can edit every row.

Records:

| Record | What happens |
| --- | --- |
| Customer | Creates a customer, or updates the existing one that matches. |
| Job | Does the same for the customer, then creates a job for them. Needs a job type that exists in your account, matched by name. |

Customer fields: `name`, `email`, `phone`, `address`, `city`, `state`, `zip_code`. Job fields: `scheduled_date`, `notes`.

Each field takes its value one of three ways:

| Choice | Meaning | Example |
| --- | --- | --- |
| Copy a field | The value at a path in the payload. Use dots for nested data. | `contact.email` |
| Combine fields | Text with `{{path}}` placeholders. | `{{first_name}} {{last_name}}` |
| Always this value | The same fixed value every time. | `Website` |

Matching an existing customer: Couplr looks for a customer in your company with the same email, then the same phone number. A phone number only matches when it is a full 10-digit number, with or without a leading `1`; shorter values never match. When one is found, the mapping's "When a matching customer already exists" setting decides whether to update their details or skip the event.

Other options:

- **Start a Speed to Lead text conversation with new leads.** Texts the homeowner as soon as a new customer is created. It is off unless you turn it on, and it never runs for a customer who already existed.
- Mapped values go through the same validation as the Couplr apps. An event whose values are invalid, such as a malformed email, fails with the reason shown. See [Processing status](#processing-status).

## Acknowledgments

> **Flip:** When I answer `202`, I'm saying "got it, it's saved". I haven't created the customer yet. That happens a moment later.

Couplr answers as soon as the event is safely stored. The answer means "received", not "processed": the customer or job is created a moment later in the background.

| Response | Body | Meaning |
| --- | --- | --- |
| `202` | `{"received": true}` | Stored. Processing is queued. |
| `200` | `{"received": true, "duplicate": true}` | Already received. Not processed again. |
| `202` | `{"received": true, "events": 3, "duplicates": 0}` | A JSON array was received: number of events and how many were duplicates. |
| `401` | `{"message": "Invalid signature."}` | Signature missing, wrong or too old. Nothing was stored. |
| `404` | `{"message": "Unknown webhook."}` | No such URL. It may have been deleted. |
| `413` | `{"message": "Payload too large."}` | Over 256 KB, or more than 25 events in one array. |
| `415` | `{"message": "Unsupported content type. Send JSON or a form post."}` | The body is not JSON or a form post. |
| `422` | `{"message": "Empty or unparseable payload."}` | Empty body, invalid JSON, or an array element that is not an object. |
| `429` | `{"message": "Too many requests."}` | Over 120 requests a minute for this endpoint. |

Treat any `2xx` as success. On `429` and `5xx`, send the same request again later. Do not retry other `4xx` responses without changing the request.

## Processing status

Each event appears in the webhook's "Recent activity" list on the Integrations page with one of these statuses:

| Status | Meaning |
| --- | --- |
| Received | Stored and waiting to be processed, or being processed. |
| Processed | The mapping ran. The entry shows what was created or updated. |
| Failed | The mapping could not be applied. The entry shows why, for example a missing name or an invalid email. |
| Skipped | Received while the webhook was not active. Nothing was created. |
| Duplicate | The same event had already been received. Nothing was created. |

A failed or skipped event can be replayed from the list once the webhook is active. A replay runs the event again with the current mapping, so the usual fix for a failed event is: correct the mapping, save, replay.

Stored events are kept for 90 days, then deleted. Deleting a webhook deletes its stored events.

## Duplicates

> **Flip:** Give each event an id with `X-Event-Id` and I'll never act on the same one twice, however many times it's sent.

Senders retry, so the same event can arrive more than once. Couplr recognizes a repeat in two ways:

- **By event id.** When you send `X-Event-Id`, a second request with the same id to the same endpoint is a duplicate, whatever its body. Ids are case-sensitive and belong to one endpoint. This is the reliable method. Use it when you can.
- **By body.** Without an event id, a request whose body is byte-for-byte identical to one received on the same endpoint in the last 24 hours is a duplicate. Two different people submitting identical data would also match, and a retry that re-serializes its JSON differently would not.

On a webhook that requires a signature, both checks apply.

Processing is safe to repeat: a customer is matched and updated instead of created twice, and a job is tied to the event that created it, so replaying that event does not book a second job.

## Retries

- **Sending.** If you get no response, a `429` or a `5xx`, send the request again. Use the same `X-Event-Id` so a request that did arrive is not processed twice.
- **Processing.** If processing fails for a temporary reason, Couplr tries again, up to 3 attempts in total, then marks the event "Failed". Invalid data fails at once without further attempts.
- **Order.** Events may be processed in a different order from the order they were sent. Do not rely on order.

## Troubleshooting

> **Flip:** Find what you're seeing in the left column. If an event failed, I keep it: fix the cause, then replay it from the activity list.

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `401 Invalid signature` | Wrong secret, or the body changed after signing, or the clock is more than 5 minutes off. | Sign the exact bytes sent. Check the secret and the system clock. |
| `404 Unknown webhook` | The URL is mistyped or the webhook was deleted. | Copy the URL again from the Integrations page. |
| `415` | The sender is posting XML, CSV or plain text. | Send JSON or a form post. |
| `422 Empty or unparseable payload` | Empty body or invalid JSON. | Check the body the sender produces. |
| `200` with `duplicate: true` | The same event id, or an identical body, was already received. | Expected for retries. For a genuinely new event, send a new `X-Event-Id`. |
| Status stays "Waiting for a sample" | No request has reached the URL yet. | Send a test submission, or paste a sample. |
| Events show "Skipped" | The webhook is not turned on. | Save a mapping, turn the webhook on, then replay the skipped events. |
| Event "Failed" with a validation message | A mapped value is missing or invalid. | Fix the mapping or the sender's data, then replay. |
| Job events fail on job type | The mapping's job type does not match a job type name in your account. | Use an exact job type name. |

## Not available yet

These are built into the service but cannot be set from the Integrations screens today, so do not plan on them:

- Bearer-token authentication for senders, and verification of Standard Webhooks signatures.
- Replacing a signing secret or a webhook URL in place. Delete the webhook and create a new one instead.
- A preview mode that shows what an event would create without creating it.
- Default values, required fields and type conversion in mappings, and matching customers by the sender's own id.
