# Sending invitations from your own system

> Sending invitation emails and rejection notices from your own email system instead of Sayproof.

Source: /en/integrate/own-sending/

By default, Sayproof sends the invitation emails on your behalf. If you want the emails to be sent from your own system (a CRM, an email service or marketing automation), Sayproof sends no email. Instead, it sends a signed HTTP request (a webhook) to your system for each invitation, and your system sends the email.

If you want no invitation emails at all, with customers writing their review on your store's order detail page, see [Without invitation emails](/en/features/invitations.md#without-invitation-emails).

The timing rules do not change: the waiting time, sending hours, daily limit, reminder and customers who do not want emails are all checked on the Sayproof side. Every request that reaches your system represents an email that should be sent.

## Setting it up

1. On the **Keys** tab of the **Channels** screen, open the **Advanced: signing passwords** section and press **Create notification address password**. The password is shown only once; copy it and store it in your system. This password is used only for this purpose and cannot sign order transfers.
2. In the **Invitation sending › Sending route** step, select **Our own system sends them**.
3. In the **Notification address** field, enter the `https` address of the endpoint on your system (up to 500 characters).

If the password or the address is missing, the setting is not saved. The sending route and the notification address can only be changed by a user signed in to the panel; a change request made with an API key receives `403 refused / panel_session_required`.

## The request

```http
POST https://crm.yourbrand.com/sayproof/notices
Content-Type: application/json
User-Agent: sayproof-webhook
X-Sayproof-Timestamp: 2026-09-28T07:30:00.123456+00:00
X-Sayproof-Event-Id: 7d0c2f3e-…
X-Sayproof-Signature: 5f1a…
```

```json
{
  "address": "jane@example.com",
  "context": {
    "brand": "Your brand",
    "channel": "web",
    "landing_url": "https://www.yourbrand.com/write-a-review",
    "line_references": ["1"],
    "order_reference": "ORD-1001"
  },
  "event_id": "7d0c2f3e-…",
  "language": "en",
  "message_class": "transactional",
  "subject": "How was your order?",
  "template_key": "default",
  "text": "…",
  "token": "sp_…"
}
```

| Field | Meaning |
|---|---|
| `address` | The recipient's email address |
| `subject` | The subject line. For reminders, "Reminder: " is added at the start. |
| `text` | The plain-text content of the email; it includes the review link and the link to stop receiving emails |
| `token` | The token used in the review link |
| `context.landing_url` | The page the invitation link opens |
| `context.channel`, `context.brand` | The code of the channel the order came from and the brand name to use in the email (the channel's **Name shown to customers**, or the brand name if it is empty). If your channels appear to customers under different names, choose the email design by these fields. |
| `context.order_reference`, `context.line_references` | The order and order lines the invitation belongs to |
| `language` | `tr` or `en` |
| `message_class` | `transactional` (a transactional email) |
| `template_key` | The key of the invitation email used; for rejection notices, `rejection:<reason>` |
| `event_id` | The request's ID; the same as the `X-Sayproof-Event-Id` header |

If you use your own email design, build the review link by adding `#review_token=` and the `token` value to the end of the `landing_url` address:

```text
https://www.yourbrand.com/write-a-review#review_token=sp_…
```

Because the token sits in the part of the address after the `#` sign, the browser does not send it to the server and it does not reach the page address recorded by analytics tools. The script also reads links in the older `?review_token=sp_…` form. The link in `text` is prepared in this form.

The link to stop receiving emails in `text` must always be included in your email.

Shopping experience invitations created for cancelled or returned lines are delivered in the same way; the subject line and text are prepared for that invitation. See [Invitations and emails](/en/features/invitations.md#process-invitation-after-a-cancellation-or-return).

### Rejection notices

The notice to be sent to the author of a review rejected by a moderator is delivered to the same address. In these requests, `template_key` has the form `rejection:<reason>`, `context` is empty, and `text` contains the rejection reason and the correction link. A rejection notice is a service notice and must be sent without delay. Do not change the text: the Turkish Advertising Board's **Guideline on Consumer Reviews** treats steering the consumer towards changing their review in a rejection notice as a manipulative practice.

## Verifying the signature

The signature has the same form as in order transfer, but it is calculated with the **Notification address password**, not a signing password: `hex(HMAC-SHA256(password, time + "." + body))`. Read the body as raw bytes; if you parse the JSON and convert it back to text, the signature will not match.

```js
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();

app.post("/sayproof/notices", express.raw({ type: "application/json" }), (request, response) => {
  const timestamp = request.get("X-Sayproof-Timestamp") ?? "";
  const given = Buffer.from(request.get("X-Sayproof-Signature") ?? "");
  const expected = Buffer.from(
    createHmac("sha256", process.env.SAYPROOF_NOTICE_SECRET).update(`${timestamp}.`).update(request.body).digest("hex")
  );
  const fresh = Math.abs(Date.now() - Date.parse(timestamp)) < 5 * 60 * 1000;
  if (!fresh || given.length !== expected.length || !timingSafeEqual(given, expected)) {
    return response.sendStatus(401);
  }
  queueEmail(JSON.parse(request.body.toString("utf-8")));
  response.sendStatus(202);
});
```

## Response and retries

- Any `2xx` response counts as success; the response body is not read. Respond within **10 seconds** and send the email in the background.
- `5xx`, `408` and `429` responses, timeouts and connection errors are treated as temporary: the send is retried after half an hour, 1 hour, 2 hours and 4 hours. If the fifth attempt also fails, the invitation's status becomes "Failed". Rejection notices are retried at the same intervals.
- For other `4xx` responses, and when the address does not meet the rules, the invitation becomes "Failed" at once.
- Every attempt is a separate send record and carries a new `event_id`. If your system received the request but the response time ran out, the same invitation may arrive again; de-duplicate with the `token` value. A reminder carries the same `token` and is told apart by the "Reminder: " prefix at the start of the subject line.
- The address must be `https` and must resolve to a public IP address. Redirects are not followed.
- The address must use the default `https` port; if it contains a port number (such as `:8443`), the request is not sent and `port_refused` is written to the send log.

Every send is recorded on the **Message log** screen. For failed sends, the reason (for example `http_status: 503`) is shown in the details. Once your system is fixed, invitations in the "Failed" state can be sent again with the **Try failed ones again** button on the **Invitations** screen; they carry the same `token` with a new `event_id`. Invitations whose link has expired are skipped ([Invitations](/en/features/invitations.md#invitations)).

## Your responsibilities

- Send the email to the `address`, with the subject line and content from the request.
- Do not add coupons, discounts, product recommendations or campaigns to the email. The invitation is a transactional email and therefore does not require the customer's separate consent; once promotional content is added, it becomes a commercial electronic message.
- Do not remove the link to stop receiving emails.
- Send the rejection notices as well.
