For shops that don't run on Shopify or WooCommerce. When an order is delivered, your site tells SaidSo. SaidSo texts the customer, has the conversation, and turns their reply into a review for you to approve.
SaidSo sends you a test key, a live key and a link to your review page.
One web request per order, with the customer's phone and how they agreed to texts.
Paste two lines into your site, or fetch approved reviews yourself.
All requests go to:
https://saidso-app.onrender.com/v1
Send your key in the Authorization header:
Authorization: Bearer sk_live_…
| Key | What it does |
|---|---|
sk_test_… | Runs every check and answers exactly like the live key, but saves nothing and texts no one. Use it while building. |
sk_live_… | Schedules real texts to real customers. |
Keep keys on your server, never in a web page or app. If a key leaks, make a new one on your SaidSo review page (section “API & setup”). The old key stops working at once.
Quick check that a key works:
curl https://saidso-app.onrender.com/v1/me \
-H "Authorization: Bearer sk_test_…"
# {"store_id":"api-kodu-pood-3f9a","name":"Kodu Pood","status":"active","test_mode":true}
POST/v1/review-requests
Call this when the customer has the order (or with send_after set to when they will). Send JSON or form fields.
| Field | Meaning |
|---|---|
order_idrequired | Your own order reference, e.g. "1042" or "ORD-1042". SaidSo texts once per order, so retries are safe. (An Idempotency-Key header works instead.) |
phonerequired | Customer's mobile. Best as +37251234567. A local number works if you also send country. |
consentrequired | How the customer agreed to texts, e.g. "checkout_checkbox", "sms_opt_in". Only send customers who agreed. |
country | Two-letter country code (EE, LV, FI…) when the phone is local. |
first_name, last_name | Shown on the review as “Mari K.”. Only the last initial is kept. |
item | What they bought, e.g. "Linen shirt". |
order_number | The number your customer sees, e.g. "1042". |
send_after | When to text, e.g. "2026-11-16T10:00:00+02:00" (max 60 days ahead). Default: 3 days from now, or right away when digital is true. |
digital | true for downloads and services the customer has immediately. |
curl https://saidso-app.onrender.com/v1/review-requests \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-d '{
"order_id": "1042",
"phone": "+37251234567",
"consent": "checkout_checkbox",
"first_name": "Mari",
"last_name": "Kask",
"item": "Linen shirt",
"send_after": "2026-11-16T10:00:00+02:00"
}'
# 201 Created
{
"id": "rr_8142",
"status": "scheduled",
"sends_at": "2026-11-16T08:00:00.000Z",
"sent_at": null,
"order_id": "1042",
"duplicate": false,
"test_mode": false
}
const res = await fetch("https://saidso-app.onrender.com/v1/review-requests", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SAIDSO_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ order_id: order.id, phone: order.phone, consent: "checkout_checkbox", first_name: order.firstName, item: order.firstItem }),
});
console.log(await res.json());
$ch = curl_init('https://saidso-app.onrender.com/v1/review-requests');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SAIDSO_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['order_id' => $order->id, 'phone' => $order->phone, 'consent' => 'checkout_checkbox']),
]);
$result = json_decode(curl_exec($ch), true);
| status | Meaning |
|---|---|
scheduled | The text will go out at sends_at (moved to daytime if that falls at night for the customer). |
skipped | Not sent, with a reason: the customer replied STOP earlier, the country isn't supported yet, or your store is paused. |
sent / failed | Later, when you check the status: the text went out, or failed after 3 tries. |
Sending the same order_id again returns the first request with "duplicate": true. Nothing is texted twice.
GET/v1/review-requests/rr_8142
Returns the same shape as above, with the current status.
GET/v1/reviews
Reviews you approved on your review page, newest first. Never includes phone numbers.
{
"average": 4.7,
"count": 12,
"data": [
{ "id": "rv_88", "stars": 5, "text": "fits perfectly, love the colour",
"name": "Mari K.", "topic": "fit", "photo_url": null, "created_at": "2026-11-16T09:12:44.000Z" }
]
}
No coding needed: paste this where the reviews should appear. Your store ID is on your review page and in your welcome message.
<script src="https://saidso-app.onrender.com/widget.js" defer></script>
<div data-saidso-shop="api-kodu-pood-3f9a"></div>
Add data-saidso-theme="dark" to the div for dark pages. It shows nothing until you have an approved review.
Put an address of your own (like https://yourshop.com/saidso-events) in your SaidSo review page, section “API & setup”. SaidSo then sends a POST there with JSON whenever something happens. Press Send test event on that page to try it.
| type | When | data |
|---|---|---|
review.created | A customer agreed to post a review. It waits for your approval. | review_id, stars, text, name, topic, order_id, photo_coming |
review.published | You approved a review. | review_id, stars, text, name, topic, order_id, photo_url |
complaint.created | Something went wrong, or the customer asked something. kind is unhappy, question or order_status. | complaint_id, kind, stars, text, phone, order_id, photo_coming |
complaint.photo_added | The unhappy customer sent a photo of the problem. | complaint_id, photo_url (fetch it with your live key) |
customer.opted_out | The customer replied STOP. SaidSo won't text them again. | phone, order_id |
POST https://yourshop.com/saidso-events
SaidSo-Event: complaint.created
SaidSo-Delivery: evt_311
SaidSo-Signature: t=1795003200,v1=5f0c…
{
"id": "evt_311",
"type": "complaint.created",
"created_at": "2026-11-16T09:20:00.000Z",
"store_id": "api-kodu-pood-3f9a",
"data": {
"complaint_id": "cp_57", "kind": "unhappy", "stars": 2,
"text": "arrived torn at the seam", "phone": "+37251234567",
"order_id": "1042", "photo_coming": true
}
}
Reply 200 within 10 seconds, then do your own work. Anything else counts as a failure, and SaidSo tries again after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h (about a day in all). Use id to ignore an event you've already handled. Redirects aren't followed.
Your page shows a signing secret (whsec_…). v1 is an HMAC-SHA256, made with that secret, of the t value, a dot, and the exact body. Compare it, and refuse events where t is more than 5 minutes old.
// Node.js (Express). Use the raw body, not parsed JSON.
const crypto = require("crypto");
app.post("/saidso-events", express.raw({ type: "application/json" }), (req, res) => {
const [, t, sig] = /t=(\d+),v1=([a-f0-9]+)/.exec(req.get("SaidSo-Signature") || "") || [];
const expected = crypto.createHmac("sha256", process.env.SAIDSO_EVENTS_SECRET)
.update(`${t}.${req.body}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!sig || !fresh || sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.sendStatus(400);
const event = JSON.parse(req.body);
res.sendStatus(200);
// …then handle event.type
});
// PHP
$body = file_get_contents('php://input');
preg_match('/t=(\d+),v1=([a-f0-9]+)/', $_SERVER['HTTP_SAIDSO_SIGNATURE'] ?? '', $m);
$expected = hash_hmac('sha256', ($m[1] ?? '') . '.' . $body, getenv('SAIDSO_EVENTS_SECRET'));
if (!$m || abs(time() - (int)$m[1]) > 300 || !hash_equals($expected, $m[2])) { http_response_code(400); exit; }
$event = json_decode($body, true);
http_response_code(200);
When a customer asks what you hold about them, or to be forgotten, pass it on (live key only):
POST/v1/customers/export · POST/v1/customers/erase
curl https://saidso-app.onrender.com/v1/customers/erase \
-H "Authorization: Bearer sk_live_…" -d phone="+37251234567"
# {"erased":true,"conversations":1,"reviews":1,"supportFlags":0}
Erasing also cancels any text still waiting to go out. If the customer once replied STOP, that is remembered so they're never texted again.
Errors come back as {"error": {"code": "…", "message": "…"}} with a message written for people.
| HTTP | code | Fix |
|---|---|---|
| 401 | unauthorized | Key missing, mistyped or replaced. |
| 400 | missing_order_id, missing_consent, missing_phone, invalid_send_after | Add or correct the field named. |
| 422 | invalid_phone | Use international form, or add country. |
| 429 | rate_limited | Over 120 requests a minute. Wait for the time in Retry-After. |
| 503 | (starting up) | The server is waking up. Retry in a few seconds. |