SaidSo

Connect any shop to SaidSo

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.

1 · Get your keys

SaidSo sends you a test key, a live key and a link to your review page.

2 · Send delivered orders

One web request per order, with the customer's phone and how they agreed to texts.

3 · Show the reviews

Paste two lines into your site, or fetch approved reviews yourself.

Keys and address

All requests go to:

https://saidso-app.onrender.com/v1

Send your key in the Authorization header:

Authorization: Bearer sk_live_…
KeyWhat 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}

Send a delivered order

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.

FieldMeaning
order_idrequiredYour own order reference, e.g. "1042" or "ORD-1042". SaidSo texts once per order, so retries are safe. (An Idempotency-Key header works instead.)
phonerequiredCustomer's mobile. Best as +37251234567. A local number works if you also send country.
consentrequiredHow the customer agreed to texts, e.g. "checkout_checkbox", "sms_opt_in". Only send customers who agreed.
countryTwo-letter country code (EE, LV, FI…) when the phone is local.
first_name, last_nameShown on the review as “Mari K.”. Only the last initial is kept.
itemWhat they bought, e.g. "Linen shirt".
order_numberThe number your customer sees, e.g. "1042".
send_afterWhen 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.
digitaltrue 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
}

Node.js

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());

PHP

$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);

What you get back

statusMeaning
scheduledThe text will go out at sends_at (moved to daytime if that falls at night for the customer).
skippedNot sent, with a reason: the customer replied STOP earlier, the country isn't supported yet, or your store is paused.
sent / failedLater, 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.

Check a request

GET/v1/review-requests/rr_8142

Returns the same shape as above, with the current status.

Get approved reviews

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" }
  ]
}

Show reviews with the widget

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.

Events: hear back the moment something happens

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.

typeWhendata
review.createdA customer agreed to post a review. It waits for your approval.review_id, stars, text, name, topic, order_id, photo_coming
review.publishedYou approved a review.review_id, stars, text, name, topic, order_id, photo_url
complaint.createdSomething 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_addedThe unhappy customer sent a photo of the problem.complaint_id, photo_url (fetch it with your live key)
customer.opted_outThe 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
  }
}

Answer quickly, with any 2xx

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.

Check it really came from SaidSo

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);

Privacy requests

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.

The rules SaidSo follows for you

Errors

Errors come back as {"error": {"code": "…", "message": "…"}} with a message written for people.

HTTPcodeFix
401unauthorizedKey missing, mistyped or replaced.
400missing_order_id, missing_consent, missing_phone, invalid_send_afterAdd or correct the field named.
422invalid_phoneUse international form, or add country.
429rate_limitedOver 120 requests a minute. Wait for the time in Retry-After.
503(starting up)The server is waking up. Retry in a few seconds.