For media partners in the Somo Media Partner program: how to send us articles over the Somo Content Service API, follow them through review and get them published.
- The API reference (OpenAPI 3.1) is the contract. This guide explains it; where they differ, the API reference wins.
- New to the program? Start with the Media Partner program overview.
1. How it works#
- You submit an article (JSON) with its sources, author and disclosures, and upload its images.
- A human editor reviews every article. They may ask for changes, approve, schedule or reject it. Articles on money, health, benefits or legal topics also get a subject-matter review.
- Approved articles are published on the property's site by us. You never get CMS or admin access.
- You follow progress by reading the submission or by receiving signed webhooks.
Nothing you send is published automatically.
2. Onboarding#
The "Apply" form on the Media Partner program page is an inquiry only. It never issues credentials. After it:
- Samples. Our editors review 2 to 3 links to your published work, then 3 to 5 sample articles you send as documents.
- Agreement. We sign an agreement covering ownership and license of text and images, originality, corrections and a takedown SLA.
- Staging access. You generate a key pair and send us the public key (§3). We create your partner record
and give you a
client_idfor staging. - Test in staging. Submit, revise, upload and receive webhooks against staging. Our editors review these like real articles; nothing is published to a live site.
- Production access. A separate
client_id(and, ideally, a separate key) for production, with a weekly submission cap.
There is no sign-up endpoint in the API.
3. Environments and your key pair#
| Base URL | Token endpoint (aud) |
|
|---|---|---|
| Staging (available first) | https://api.staging.content.somo.financial |
https://api.staging.content.somo.financial/oauth/token |
| Production | https://api.content.somo.financial |
https://api.content.somo.financial/oauth/token |
Staging and production are separate: a client_id, key, webhook or submission in one does not exist in the other.
Generate a key pair (ES256, P-256). Keep the private key in your secrets store; never send it to anyone, including us.
openssl ecparam -name prime256v1 -genkey -noout -out scs-private.pem
openssl pkcs8 -topk8 -nocrypt -in scs-private.pem -out scs-private.pk8.pem
openssl ec -in scs-private.pem -pubout -out scs-public.pemTurn the public key into a JWK and send us the output (it contains no secret):
node -e '
const crypto = require("crypto");
const jwk = crypto.createPublicKey(require("fs").readFileSync("scs-public.pem")).export({ format: "jwk" });
Object.assign(jwk, { kid: "partner-key-2026-10", alg: "ES256", use: "sig" });
console.log(JSON.stringify(jwk));
'{"kty":"EC","x":"...","y":"...","crv":"P-256","kid":"partner-key-2026-10","alg":"ES256","use":"sig"}RSA (RS256, 2048 bits or more) is also accepted. You may register up to 3 keys at once; to rotate, send us the new
public key, switch to it, then ask us to retire the old one.
4. Getting an access token#
You sign a short JWT (the client assertion) with your private key and exchange it for a 15-minute access token.
| Claim | Value |
|---|---|
header alg |
the algorithm registered for your key: ES256 (or RS256) |
header kid |
the kid you registered |
| other headers | no jku, jwk, x5u, x5c or crit (refused); typ may be JWT |
iss, sub |
your client_id |
aud |
a single string: the token endpoint of the environment (table above), exactly. Not an array |
iat |
now (Unix seconds) |
exp |
at most 300 seconds after iat |
jti |
a new random value every time; a reused jti is refused |
The token lasts 15 minutes (expires_in: 900). Cache it and get a new one shortly before it expires; there is no
refresh token.
Node (npm i jose)#
// token.mjs
import { readFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { importPKCS8, SignJWT } from 'jose';
const BASE = process.env.SCS_BASE_URL ?? 'https://api.staging.content.somo.financial';
const CLIENT_ID = process.env.SCS_CLIENT_ID;
const KID = process.env.SCS_KID ?? 'partner-key-2026-10';
const key = await importPKCS8(readFileSync('scs-private.pk8.pem', 'utf8'), 'ES256');
export async function getToken(scope) {
const tokenUrl = `${BASE}/oauth/token`;
const assertion = await new SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: KID, typ: 'JWT' })
.setIssuer(CLIENT_ID)
.setSubject(CLIENT_ID)
.setAudience(tokenUrl)
.setIssuedAt()
.setExpirationTime('120s')
.setJti(randomUUID())
.sign(key);
const res = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: assertion,
...(scope ? { scope } : {}),
}),
});
if (!res.ok) throw new Error(`token: ${res.status} ${await res.text()}`);
return (await res.json()).access_token;
}Python (pip install pyjwt[crypto] requests)#
# token.py
import os, time, uuid
import jwt, requests
BASE = os.environ.get("SCS_BASE_URL", "https://api.staging.content.somo.financial")
CLIENT_ID = os.environ["SCS_CLIENT_ID"]
KID = os.environ.get("SCS_KID", "partner-key-2026-10")
PRIVATE_KEY = open("scs-private.pk8.pem").read()
def get_token(scope=None):
token_url = f"{BASE}/oauth/token"
now = int(time.time())
assertion = jwt.encode(
{"iss": CLIENT_ID, "sub": CLIENT_ID, "aud": token_url,
"iat": now, "exp": now + 120, "jti": str(uuid.uuid4())},
PRIVATE_KEY, algorithm="ES256", headers={"kid": KID, "typ": "JWT"},
)
data = {
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion,
}
if scope:
data["scope"] = scope
r = requests.post(token_url, data=data, timeout=10)
r.raise_for_status()
return r.json()["access_token"]curl#
curl cannot sign the JWT, so make the assertion with a script first (for example the Node code above, printing
assertion), then:
curl -sS https://api.staging.content.somo.financial/oauth/token \
-d grant_type=client_credentials \
-d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
-d client_assertion="$ASSERTION" \
-d scope="submissions:write submissions:read:own assets:write webhooks:manage submit:property:example-property"{"access_token":"scs_at_q8Yp2...","token_type":"Bearer","expires_in":900,"scope":"submissions:write submissions:read:own assets:write webhooks:manage submit:property:example-property"}A failed token request answers 401 {"error":"invalid_client"}. For security, the response does not say which
check failed, so work through the list below.
Send only the assertion: no Authorization: Basic header, and a normal signed JWT (three dot-separated parts),
not an encrypted one.
Check, in order: the aud is exactly the token endpoint of the environment you are calling; iss and sub are
your client_id; kid matches what you registered; your clock is right (within 60 seconds); exp - iat ≤ 300;
jti is new; aud is a string, not an array. If you send client_id in the form, it must equal iss.
Scopes#
| Scope | Lets you |
|---|---|
submissions:write |
submit, revise, withdraw |
submissions:read:own |
read your submissions and GET /v1/properties |
assets:write |
upload and read images |
webhooks:manage |
set your webhook endpoint |
submit:property:<id> |
submit to that property, e.g. submit:property:example-property |
Leave out scope to get every scope you hold.
5. Submitting an article#
First, see where you may submit:
curl -sS https://api.staging.content.somo.financial/v1/properties -H "Authorization: Bearer $TOKEN"It lists each property's id, the categories and tags it accepts, whether your agreement covers YMYL topics
(ymylAllowed) and your remaining quota.
Then submit. Every POST that creates something needs an Idempotency-Key (§10).
curl -sS https://api.staging.content.somo.financial/v1/submissions \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
--data @article.jsonarticle.json:
{
"schemaVersion": "1",
"property": "example-property",
"externalRef": "ptr-2026-10-0042",
"title": "How a PCS move affects your state income taxes",
"excerpt": "Moving on orders can change which state taxes you. Here is how military residency rules work, what the spouse residency law covers, and what to check before you file.",
"body": {
"format": "markdown",
"content": "## Your state of legal residence usually stays the same\n\nUnder the [Servicemembers Civil Relief Act](https://www.justice.gov/servicemembers/servicemembers-civil-relief-act-scra), moving on military orders does not by itself change your state of legal residence.\n\n\n"
},
"seo": {
"metaTitle": "PCS moves and state income tax, explained",
"metaDescription": "How military residency rules decide which state taxes you after a PCS move, and what spouses need to know before filing.",
"slug": "pcs-move-state-income-taxes"
},
"category": "money",
"tags": ["taxes", "pcs"],
"sources": [
{ "url": "https://www.justice.gov/servicemembers/servicemembers-civil-relief-act-scra",
"title": "Servicemembers Civil Relief Act (SCRA)", "publisher": "U.S. Department of Justice" },
{ "url": "https://www.irs.gov/individuals/military", "title": "Military tax information",
"publisher": "Internal Revenue Service" }
],
"author": { "name": "Alex Example", "role": "expert",
"credentials": "Enrolled Agent", "consentOnFile": true },
"disclosure": { "aiAssisted": false, "sponsored": false, "sponsoredLinks": [] },
"ymyl": true,
"assets": [
{ "assetId": "01920c4d-1a2b-7c3d-8e4f-5a6b7c8d9e0f", "role": "hero",
"alt": "Moving truck parked outside base housing" }
]
}The answer is 201 with the submission (status: "received") and its id. Keep the id.
Field rules#
| Field | Rule |
|---|---|
externalRef |
optional, your own id: up to 128 of A–Z a–z 0–9 . _ : / - |
title |
20 to 110 characters |
language |
optional, default en-US, any case (en-us is read as en-US); one of the property's languages |
excerpt |
50 to 300 characters, plain text |
body |
Markdown (preferred) or HTML, at most 204,800 bytes of UTF-8. An h1 becomes h2 (the title is the heading). Links: absolute https URLs or a same-page #fragment; any other link, including http:, is refused with 422 validation-failed |
seo.metaTitle / seo.metaDescription |
10 to 60 / 50 to 160 characters |
seo.slug |
optional suggestion, lowercase words and hyphens; we may change it |
category, tags |
slugs (lowercase letters, digits, hyphens) from GET /v1/properties; up to 10 tags |
sources |
required, 1 to 50, each an https URL and a title. Use primary sources (the government agency, the official program page) for benefits, pay, tax and health |
author |
who actually wrote or reviewed it, and whether they consented to be credited. Our editors decide the byline |
disclosure |
whether AI tools were used to draft or edit the text; whether anything is sponsored (then sponsorName is required); every paid or affiliate URL in sponsoredLinks (https only) |
ymyl |
true for money, health, benefits or legal guidance |
requestedPublishDate |
optional date you would like; advisory only, our editors schedule |
assets |
up to 20 images you uploaded, each listed once, at most one hero, each with alt text |
- Unknown fields are refused, so a typo is an error, not silently ignored.
- Line endings and control characters.
- Windows (CRLF) and old Mac (CR) line endings are converted to LF before checking.
- Control and format characters (Unicode
Cc/Cf) are refused, for example zero-width spaces and bidi overrides (U+202A–U+202E, U+2066–U+2069). - Allowed: tabs and newlines in
body, and U+200C, U+200D and U+FE0F (joiners and the emoji variation selector) inbodyand in plain-text fields.
- Text fields are plain text. Title, excerpt, SEO fields, author name, credentials and bio, alt text, captions,
credits, sponsor name, source titles, notes, and an image's file name, license source and license proof may not
contain
<or>. Write "less than" instead of<. Onlybodycarries markup. - Expect new values. We may add statuses, event types, reasons and fields. Treat a value you don't recognize as "something else", never as an error.
- HTML is cleaned to a short allow-list (paragraphs,
h2–h4, lists, quotes, emphasis, tables, links, images). Scripts, styles, iframes, forms, embeds, classes, ids, inline styles and plain comments are removed. HTML containing a CDATA section, a declaration (such as<!DOCTYPE>), a processing instruction (<?…?>) or a comment with markup inside is refused with422 validation-failed. We set every link'srelourselves: you cannot setrel,targetornofollow. - Images in the body must be
asset:<id>of an image you uploaded. External image URLs are refused; we never fetch a URL you send. - Do not set a canonical URL. Each article has one home, on our site. Articles already published elsewhere are not accepted.
6. Uploading an image#
-
Register it (needs
assets:write): the exact size, the SHA-256 of the exact bytes, and the license (the field is spelledlicence).SHA=$(shasum -a 256 hero.jpg | cut -d' ' -f1) # macOS; on Linux: sha256sum hero.jpg | cut -d' ' -f1 SIZE=$(wc -c < hero.jpg | tr -d ' ') curl -sS https://api.staging.content.somo.financial/v1/assets \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d "{\"filename\":\"hero.jpg\",\"contentType\":\"image/jpeg\",\"sizeBytes\":$SIZE,\"sha256\":\"$SHA\", \"aiGenerated\":false, \"licence\":{\"source\":\"Our staff photographer\",\"type\":\"work_for_hire\",\"proof\":\"Employment agreement\"}}" \ > asset.jsonThe answer has
asset.idand anuploadblock: a URL, the headers to send, and when it expires (15 minutes). -
Upload the bytes to
upload.urlwith exactly the headers inupload.headers(they are part of the signature; the checksum header is the base64 SHA-256 of the file):args=() while IFS= read -r h; do args+=(-H "$h"); done \ < <(jq -r '.upload.headers | to_entries[] | "\(.key): \(.value)"' asset.json) curl -sS -X PUT "$(jq -r .upload.url asset.json)" "${args[@]}" --data-binary @hero.jpg -
Wait for it to be processed.
GET /v1/assets/{id}moves throughpending_upload,scanning,processingand endscleanorrejected(withrejectReason). Usually under a minute.GET /v1/assetslists your assets.If the register call timed out, retry it with the same
Idempotency-Key: while the asset is still waiting for its upload you get a freshly signeduploadblock; once the asset has moved on,uploadisnull.
Rules:
- JPEG, PNG, WebP or AVIF; at most 10 MB and 40 megapixels; a single frame (no animation). The type is read from the file's bytes, not its name.
- Every image is malware-scanned, re-encoded (longest side at most 2560 px) and stripped of metadata (EXIF, GPS). We publish the processed copy, never your original.
- A license is required for every image: where it came from, the license type, and proof (a license id, agreement reference or URL). Say whether it is AI-generated.
- You can reference an image in a submission before it is
clean, but the article cannot be approved until all its images are.
7. Reading status#
curl -sS https://api.staging.content.somo.financial/v1/submissions/$ID -H "Authorization: Bearer $TOKEN"
curl -sS "https://api.staging.content.somo.financial/v1/submissions?status=changes_requested,approved&limit=50" \
-H "Authorization: Bearer $TOKEN"
curl -sS "https://api.staging.content.somo.financial/v1/submissions?updatedSince=2026-10-19T00:00:00Z" \
-H "Authorization: Bearer $TOKEN"updatedSince returns everything that changed since a time, which is the easiest way to stay in sync.
currentRevision counts your revisions only (1 = the original); our editors' edits are not numbered.
When an article is live, publications lists where (property, url, publishedAt, state).
GET /v1/submissions/{id}/published returns the final published text, including any edits our editors made.
Lists are newest first. If the response has a nextCursor, pass it as ?cursor= for the next page; null means the
last page.
| Status | Meaning | What you can do |
|---|---|---|
received |
we have it | wait, or withdraw |
in_review |
an editor is on it | wait, or withdraw |
changes_requested |
see feedback.note |
send a revision, or withdraw |
approved |
approved for publication | wait, or withdraw (before it goes live) |
scheduled |
will go live at publishAt |
wait, or withdraw (before it goes live) |
published |
live; see publications |
file a correction request (§8) |
rejected |
final; see feedback.note |
submit a new article if appropriate |
withdrawn |
you withdrew it | nothing |
unpublished |
taken down by our editors | nothing |
Prefer webhooks (§9) to polling. If you poll, once every few minutes per submission is plenty.
8. Revisions after "changes requested"#
When the status is changes_requested, read feedback.note, fix the article and send the whole article again
(every field, not only what changed), plus an optional noteToEditor:
curl -sS https://api.staging.content.somo.financial/v1/submissions/$ID/revisions \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" --data @article-v2.json- Only allowed in
changes_requested; at any other time the answer is409 invalid-state. propertyandexternalRefcannot change in a revision (they are not accepted).- The submission goes back to
in_reviewand you getsubmission.revised. Revisions do not count against your quota. - Our editors may also edit the text themselves before publishing. You will see the published result at its URL.
Withdrawing: POST /v1/submissions/{id}/withdraw (optional body {"reason": "..."}), allowed until the article
starts publishing. While a publication is in progress the answer is 409 publishing-in-progress with Retry-After.
Retry-Afteris how long to wait before trying again: at least 5 seconds, at most 3600. Publishing usually completes within seconds.Retry-Afteris 3600 while one of our editors needs to complete the publication, which can take until an editor acts on it.
Once publishing has started, we complete it, or confirm it did not happen, before any other change to the submission takes effect. If the article went live in the meantime, file a correction request instead.
Corrections and takedowns after publication: file a correction request. Our editors handle it under the SLA in
your agreement. You can have one open request of each kind per submission (a takedown request can be filed while
a correction request is open). You can propose the corrected text as a full article; it is validated and cleaned
exactly like a revision. Editors make the change, the article is updated in place (same URL) and you get
submission.updated:
curl -sS https://api.staging.content.somo.financial/v1/submissions/$ID/correction-requests \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"kind":"correction","reason":"The state filing deadline in section 3 changed for the 2026 tax year.",
"details":"The deadline moved to May 1; the article still says April 15."}'Its status and our editor's note appear in correctionRequests on GET /v1/submissions/{id}. Only our editors
change the status, and each change carries a note:
open: received, not yet looked at;accepted: an editor is acting on it;done: closed as completed; the note says what was done;declined: closed without the change you asked for; the note says why.
When an editor closes a request (done or declined) you also get the correction_request.closed webhook. The
article's own changes still arrive as their usual events (submission.updated, submission.unpublished). Use kind: "takedown" to ask for removal. A takedown request works even if your access to that property has
since ended; a correction with proposed text needs your current access, like a revision.
9. Webhooks#
Register one endpoint (needs webhooks:manage). HTTPS on port 443, with a DNS host name reachable from the
internet: no IP addresses, no user@ in the URL. We do not follow redirects. We read at most the first 4 KB of your
response (we need it only for the verification challenge below) and never store it.
curl -sS -X PUT https://api.staging.content.somo.financial/v1/webhooks \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"url":"https://hooks.partner.example/scs","events":["submission.status_changed","submission.received","submission.changes_requested","submission.approved","submission.published","submission.updated","submission.rejected","submission.unpublished","correction_request.closed"]}'The first PUT answers 201 with a signingSecret. Store it now; it is not shown again, except to a retry with
the same Idempotency-Key within 10 minutes.
Then your endpoint must pass verification.
- We POST a signed
endpoint.verificationevent with a randomchallenge. - Your endpoint must answer
200within 10 seconds with{"challenge": "<the same value>"}. The handlers below do this. - Until it does, the endpoint is
pending_verification. Events are held for up to 24 hours and delivered once you pass; older ones are dropped, so after a late verification catch up withGET /v1/submissions?updatedSince=. - Changing the URL starts verification again and cancels any verification still pending.
- Verification requests are limited to 5 an hour (
429 rate-limited). Each one counts: registering the endpoint, changing its URL, and sending the same URL again while it is still pending or failed verification. - Send an
Idempotency-Keywith thePUT, especially when rotating. If the response is lost, retrying with the same key within 10 minutes returns the same response, including the same new secret, as long as it is still the current one. After 10 minutes, or after another rotation, the retry returns the endpoint without a secret. AfterDELETE /v1/webhooks, a retry answers404 not-found. - To rotate it, send the same
PUTwith"rotateSecret": true. You get a new secret, and for 24 hours we sign with both. - A second rotation inside those 24 hours is refused (
409 secret-rotation-in-progress). POST /v1/webhooks/testsends a signedwebhook.testevent to a verified endpoint, at most 5 times an hour.DELETE /v1/webhooksremoves the endpoint.
Events:
| Type | When | Extra fields in data |
|---|---|---|
submission.status_changed |
every status change | from, to, at |
submission.received |
a submission was accepted | |
submission.revised |
a revision was accepted | |
submission.changes_requested |
an editor asked for changes | feedback |
submission.approved |
approved for publication | |
submission.scheduled |
scheduled | publishAt |
submission.published |
live | property, url, publishedAt |
submission.updated |
the live article changed: a correction applied, or our editors re-published it (possibly at a new url) |
property, url, updatedAt |
submission.rejected |
rejected | feedback |
submission.withdrawn |
you withdrew it | |
submission.unpublished |
taken down by our editors | |
correction_request.closed |
an editor closed one of your correction requests | correctionRequest: {id, status (done / declined), note} |
webhook.test |
you called POST /v1/webhooks/test |
(empty) |
endpoint.verification |
your endpoint was registered, its URL changed, or you sent the same URL again while it was pending or had failed verification | challenge (echo it back) |
submission.status_changed is the authoritative record of state. It is not sent when a submission is
created (submission.received covers that) or for changes that keep the status. Apart from those, it is sent for
every transition, so if you handle only one event, handle that one. The others are conveniences with extra
fields. Correction requests are not covered: their close arrives as correction_request.closed, and accepted
shows only via GET /v1/submissions/{id} (or updatedSince).
Every event has id, type, schemaVersion, occurredAt and data. Submission events and
correction_request.closed carry data.{submissionId, externalRef, property, status, revision}, where revision is your revision number. We may add
event types and fields: ignore what you do not recognize.
Delivery: answer any 2xx within 10 seconds; do the work afterwards. Otherwise we retry with exponential
backoff for 24 hours. Retries carry the same SCS-Event-Id: use it to drop duplicates. Events can arrive out of
order; use occurredAt, or read the submission.
Verify every delivery. The header is
SCS-Signature: t=1760796245,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdwhere v1 is the lowercase hex HMAC-SHA256 of the string <t>.<raw request body>.
- The key is your whole
signingSecretstring as UTF-8 bytes, including itsscs_wh_prefix. Do not decode it. - Compute it over the raw bytes you received, before any JSON parsing. A framework that parses and
re-serializes the body changes the bytes and breaks the signature. In Express use
express.raw()on this route; in Flask userequest.get_data(). - Compare in constant time.
- Accept if any
v1matches (there are two during a rotation). - Reject if
tis more than 5 minutes from your clock.
Node (Express)#
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.SCS_WEBHOOK_SECRET; // the whole string, including "scs_wh_"
const TOLERANCE_S = 300;
const HEX64 = /^[0-9a-f]{64}$/;
export function verifySignature(header, rawBody, secret = SECRET, now = Math.floor(Date.now() / 1000)) {
if (typeof header !== 'string' || !Buffer.isBuffer(rawBody)) return false;
const pairs = header.split(',').map((p) => p.trim()).map((p) => [p.slice(0, p.indexOf('=')), p.slice(p.indexOf('=') + 1)]);
const ts = pairs.filter(([k]) => k === 't').map(([, v]) => v);
const sigs = pairs.filter(([k]) => k === 'v1').map(([, v]) => v.toLowerCase()).filter((v) => HEX64.test(v));
if (ts.length !== 1 || !/^[0-9]{1,12}$/.test(ts[0]) || sigs.length === 0) return false;
const t = Number(ts[0]);
if (Math.abs(now - t) > TOLERANCE_S) return false;
const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(`${t}.`).update(rawBody).digest();
return sigs.some((s) => crypto.timingSafeEqual(Buffer.from(s, 'hex'), expected));
}
const app = express();
// express.raw keeps the exact bytes; do not put express.json() in front of this route.
app.post('/scs', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
if (!verifySignature(req.get('SCS-Signature'), req.body)) return res.sendStatus(400);
const event = JSON.parse(req.body);
if (event.type === 'endpoint.verification') return res.json({ challenge: event.data.challenge });
res.sendStatus(204); // acknowledge first, then process (dedupe on event.id)
console.log(event.type, event.data?.submissionId);
});
app.listen(8080);Python (Flask)#
import hashlib, hmac, json, os, re, time
from flask import Flask, request
SECRET = os.environ["SCS_WEBHOOK_SECRET"].encode("utf-8") # the whole string, including "scs_wh_"
TOLERANCE_S = 300
HEX64 = re.compile(r"[0-9a-f]{64}")
def verify_signature(header, raw_body, secret=SECRET, now=None):
try:
if not isinstance(header, str) or not isinstance(raw_body, bytes):
return False
pairs = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
ts = [v for k, v in pairs if k == "t"]
sigs = [v.lower() for k, v in pairs if k == "v1"]
sigs = [v for v in sigs if HEX64.fullmatch(v)]
if len(ts) != 1 or not (ts[0].isascii() and ts[0].isdigit()) or len(ts[0]) > 12 or not sigs:
return False
now = int(time.time()) if now is None else now
if abs(now - int(ts[0])) > TOLERANCE_S:
return False
expected = hmac.new(secret, ts[0].encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
except (ValueError, TypeError, UnicodeError):
return False
app = Flask(__name__)
@app.post("/scs")
def scs_webhook():
raw = request.get_data() # raw bytes, before any parsing
if not verify_signature(request.headers.get("SCS-Signature"), raw):
return "", 400
event = json.loads(raw)
if event.get("type") == "endpoint.verification":
return {"challenge": event["data"]["challenge"]}, 200
print(event["type"], event.get("data", {}).get("submissionId")) # dedupe on event["id"]
return "", 20410. Errors, retries and idempotency#
Errors are application/problem+json:
{
"type": "https://api.content.somo.financial/problems/validation-failed",
"title": "The submission is not valid",
"status": 422,
"errors": [
{ "pointer": "/seo/metaDescription", "detail": "Must be 50 to 160 characters; got 212." },
{ "pointer": "/sources", "detail": "At least one source is required." }
],
"requestId": "req_01920c4e8b81"
}Branch on type, not on title or detail. An id in the URL that isn't a UUID gets 404 not-found, the same as
one that doesn't exist. Quote requestId (also the X-Request-Id header) when you contact
us. The list of types is in the API reference.
What to retry:
| Answer | Retry? |
|---|---|
401 invalid-token |
get a new token, then retry once |
409 publishing-in-progress (withdraw only) |
yes, after Retry-After (5 to 3600 seconds) |
429 rate-limited |
yes, after Retry-After |
429 quota-exceeded |
not until Retry-After (the next free slot) |
500, 502, 503, 504, timeouts, connection errors |
yes, with the same Idempotency-Key, backing off (1 s, 2 s, 4 s … up to a minute, with jitter). A 503 means the request couldn't be completed right now (for example, your earlier request with the same key is still running): wait for Retry-After, then retry with the same key |
other 4xx |
no: fix the request, then send it with a new Idempotency-Key |
Idempotency. Send a new Idempotency-Key (a UUID) for every new thing you create, and the same key when you
retry it. A retry with the same key and the same body gets the original answer back (header
Idempotent-Replayed: true), so a timeout never creates a duplicate article.
- The same key with a different body is
409 idempotency-key-reused. - We store and replay only successes and schema-level validation answers (
400,422 validation-failedfrom the schema). Anything else, including checks against a property's categories, tags or languages, is worked out again on retry. - After you fix a refused request, use a NEW
Idempotency-Key. Reusing the old one with the corrected body gets409 idempotency-key-reused. - Keys are kept for 30 days and are per credential.
Also set externalRef to your own id for the article. It is returned everywhere and filterable
(GET /v1/submissions?externalRef=, which returns a list). It is refused if already used on one of your
submissions that isn't rejected or withdrawn.
11. Limits and quotas#
| Limit | Value |
|---|---|
| Access token lifetime | 15 minutes |
| Client assertion lifetime | at most 5 minutes |
| Requests | 60 a minute per credential (bursts of 20); token endpoint 10 a minute |
| Request body | 256 KB; article body 204,800 bytes |
| Images | 20 per article, 10 MB and 40 megapixels each |
| Sources | 1 to 50 per article |
| Tags | 10 per article |
| Submissions | the weekly submission cap agreed with you for each property, over a rolling window; your current quota is in GET /v1/properties |
Every response carries RateLimit-Limit (your token bucket's capacity), RateLimit-Remaining (tokens left) and
RateLimit-Reset (seconds until the bucket is full again). Revisions do not
count against your quota; withdrawn and rejected submissions do.
12. Content rules at a glance#
The full rules are in your agreement. In short:
- A human reviews every article. Nothing is published without an editor's approval; money, health, benefits and legal topics also need a subject-matter reviewer.
- Sources are required. Primary sources for anything about benefits, pay, taxes or health.
- Real authors. The author is a real person who wrote or reviewed the piece and agreed to be named.
- No undisclosed sponsored or affiliate links. List every paid or affiliate URL in
disclosure.sponsoredLinks; they are markedrel="sponsored". Undisclosed ones are grounds for rejection. - No link schemes. No links placed for search ranking, link exchanges or repeated exact-match anchor text. We review every outbound link.
- Tell us if AI tools were used (
disclosure.aiAssisted) to draft or edit any part of the text. Human writers are responsible for the article, whatever tools they used. - Image license required for every image, and say if it is AI-generated.
- Original and exclusive. One home per article: ours. No canonical URLs elsewhere, no republished pieces.
- A weekly submission cap agreed with you. The number is in your agreement and in
GET /v1/properties.
13. Checklist before you go to production#
- Tokens: cached, renewed before expiry, a new
jtieach time. - Every create call sends an
Idempotency-Key; retries reuse it. -
externalRefset on every submission. - Images uploaded with licenses; articles reference them as
asset:<id>. - Webhook endpoint answers
2xxquickly, verifiesSCS-Signatureon the raw body, rejects old timestamps, and dedupes onSCS-Event-Id.POST /v1/webhooks/testarrives and verifies. - Unknown statuses, event types and fields are tolerated.
-
changes_requestedhandled: you readfeedback.noteand send a full revision. - Errors handled by
type; retries only where §10 says. - Your private key is in a secrets store, and you know how to rotate it.