Skip to content

Media Partner Integration Guide

Everything your engineers need to send us articles, follow them through review and get them published. The API reference has every endpoint and field.

On this page1. How it works

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.


1. How it works#

  1. You submit an article (JSON) with its sources, author and disclosures, and upload its images.
  2. 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.
  3. Approved articles are published on the property's site by us. You never get CMS or admin access.
  4. 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:

  1. Samples. Our editors review 2 to 3 links to your published work, then 3 to 5 sample articles you send as documents.
  2. Agreement. We sign an agreement covering ownership and license of text and images, originality, corrections and a takedown SLA.
  3. Staging access. You generate a key pair and send us the public key (§3). We create your partner record and give you a client_id for staging.
  4. 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.
  5. 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.

Shell
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.pem

Turn the public key into a JWK and send us the output (it contains no secret):

Shell
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));
'
JSON
{"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)#

JavaScript
// 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)#

Python
# 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:

Shell
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"
JSON
{"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:

Shell
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).

Shell
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.json

article.json:

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![Moving truck outside base housing](asset:01920c4d-1a2b-7c3d-8e4f-5a6b7c8d9e0f)\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) in body and 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 <. Only body carries 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 with 422 validation-failed. We set every link's rel ourselves: you cannot set rel, target or nofollow.
  • 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#

  1. Register it (needs assets:write): the exact size, the SHA-256 of the exact bytes, and the license (the field is spelled licence).

    Shell
    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.json

    The answer has asset.id and an upload block: a URL, the headers to send, and when it expires (15 minutes).

  2. Upload the bytes to upload.url with exactly the headers in upload.headers (they are part of the signature; the checksum header is the base64 SHA-256 of the file):

    Shell
    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
  3. Wait for it to be processed. GET /v1/assets/{id} moves through pending_upload, scanning, processing and ends clean or rejected (with rejectReason). Usually under a minute. GET /v1/assets lists 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 signed upload block; once the asset has moved on, upload is null.

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#

Shell
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:

Shell
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 is 409 invalid-state.
  • property and externalRef cannot change in a revision (they are not accepted).
  • The submission goes back to in_review and you get submission.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-After is how long to wait before trying again: at least 5 seconds, at most 3600. Publishing usually completes within seconds.
  • Retry-After is 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:

Shell
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.

Shell
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.verification event with a random challenge.
  • Your endpoint must answer 200 within 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 with GET /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-Key with the PUT, 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. After DELETE /v1/webhooks, a retry answers 404 not-found.
  • To rotate it, send the same PUT with "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/test sends a signed webhook.test event to a verified endpoint, at most 5 times an hour.
  • DELETE /v1/webhooks removes 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

Example
SCS-Signature: t=1760796245,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

where v1 is the lowercase hex HMAC-SHA256 of the string <t>.<raw request body>.

  • The key is your whole signingSecret string as UTF-8 bytes, including its scs_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 use request.get_data().
  • Compare in constant time.
  • Accept if any v1 matches (there are two during a rotation).
  • Reject if t is more than 5 minutes from your clock.

Node (Express)#

JavaScript
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)#

Python
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 "", 204

10. Errors, retries and idempotency#

Errors are application/problem+json:

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-failed from 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 gets 409 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 marked rel="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 jti each time.
  • Every create call sends an Idempotency-Key; retries reuse it.
  • externalRef set on every submission.
  • Images uploaded with licenses; articles reference them as asset:<id>.
  • Webhook endpoint answers 2xx quickly, verifies SCS-Signature on the raw body, rejects old timestamps, and dedupes on SCS-Event-Id. POST /v1/webhooks/test arrives and verifies.
  • Unknown statuses, event types and fields are tolerated.
  • changes_requested handled: you read feedback.note and 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.