Nischal Tiwari

From the scorer’s tap to a live Facebook post: building an over-summary automation

How I wired WordPress, n8n and the Facebook Graph API so one button publishes a cricket over to social — and the page-token gotcha that ate an afternoon.

During a live cricket match, the most valuable thing we can publish is an over that just changed the game — a tight final over, a wicket maiden, a chase that flipped. By the time someone published the score into Facebook by hand, the moment was already gone. I wanted the person scoring the match to be able to publish that over with a single tap, and have a properly written caption land on our Facebook page on its own.

This is the story of how I built that — the plan, the dead end I inherited, the rebuild, and the one authentication detail that cost me an afternoon.

The problem I started with

cricnepal runs a custom live-scoring engine inside WordPress (a mu-plugin we call the cn-engine). The scorer enters every ball; the public match centre reads from it. So the data was already there. What was missing was the bridge to social.

There was an old bridge, technically. WordPress had a settings field — “Live Match Over Summaries” — and a webhook URL sitting in it. I assumed it worked. It did not. When I traced the code, the option was registered and rendered in the admin, but nothing ever fired it. It was dead wiring: a switch screwed to the wall with no cable behind it. The only webhooks that actually POSTed were the on-publish share and a manual alert — neither of which triggered on an over ending.

So I was not fixing a broken feature. I was building one, and inheriting a misleading settings field that made it look solved.

Lesson I keep relearning: “there’s already a field for it” is not the same as “it works.” Grep the trigger, not the setting.

How I planned it

I wrote down three decisions before touching any code.

  1. Operator-gated, not automatic. My first instinct was to fire on every over end. I killed that idea fast — six posts an over-pair, every over, is spam, and it strips away editorial judgement. Instead: the scorer gets a “Post this over” button. It defaults to the last completed over. The human decides which over is worth it.
  2. PHP owns the caption. The old make.com scenario I inherited composed the caption inside the automation tool, using nested formula logic like if(length(batters[2].name) > 0; ...). That is brittle, untestable, and invisible to version control. I decided the WordPress side would compose a ready-to-post caption string and send it. The automation tool would do nothing clever — just take {{caption}} and post it. Belt and suspenders: I’d also send the structured over data, so a future channel could reformat if needed.
  3. The receiver is swappable. WordPress would POST to a configurable webhook URL. Whatever sat on the other end — make.com, n8n, anything — was a config flip, not a code change.

Choosing the receiver: why n8n, not make.com

I prototyped against make.com first because the inherited scenario lived there. Then I hit the wall everyone hits on the free tier: two active scenarios, maximum. I already wanted room for Twitter/X as a second route, plus other automations down the line. Two slots was a non-starter.

I looked at the alternatives and landed on n8n, self-hosted:

  • It’s free for internal use under its Sustainable Use License.
  • I could run it on a box we already pay for — no per-operation credits ticking away.
  • It speaks plain webhooks and has native nodes for the Graph API.

The trade was that I’d own the hosting. Given we already run a Dedicated box behind RunCloud, that was a trade I was happy to make.

The architecture, end to end

Scorer taps "Post this over"
        │
        ▼
WordPress REST  POST /match/{id}/cn-engine/over-summary-share
        │   (builds caption in PHP, reuses the over-summary builder)
        ▼
Outbound webhook  →  https://automate.cricnepal.com/webhook/...
        │   (non-blocking, fail-soft — a social hiccup never stalls scoring)
        ▼
n8n  Webhook trigger  →  Facebook Graph node  →  POST /{page-id}/feed
        │
        ▼
Live post on the cricnepal Facebook page

The two things I care about most in that diagram: the WordPress call is non-blocking and fail-soft (if the webhook is down, the scorer’s match never notices), and the caption is built before it leaves WordPress.

Step 1 — The WordPress side

I added one new file to the cn-engine, over-summary-webhook.php, that does three jobs:

  1. Build the payload. It reuses the existing over-summary builder, so the runs, the ball sequence, the bowler’s figures, and the batters at the crease all come from the same source the match centre already trusts.
  2. Compose the caption in PHP. This is the part I deliberately pulled out of the automation tool. The wording lives in git, where I can read it, test it, and change it in a pull request.
  3. Send it, safely. A non-blocking POST to the configured hook, logging on failure and never throwing into the scoring flow.

Here’s the shape of the caption the function emits:

🏏 Australia vs India
End of Over 10 • AUS 30/5
4 runs • 1 0 0 W 2 1
Zaheer Khan 12/2 (4.0 ov)
Ricky Ponting 18* (22) · Adam Gilchrist 4 (8)
#AUSvIND #LiveCricket

To prove it before anything went near a live page, I ran the builder against a real local match with wp eval and inspected the caption and the structured payload. Composing the text in PHP made this trivial — it’s just a function returning a string, so I could test it without ever calling Facebook.

On the scorer’s screen I added a small post-over-button.tsx component — “Post this over” — that reads the match store, defaults to the last completed over, calls the REST endpoint, and shows the result inline. Nothing fancy. The whole point was that it’s one tap.

Step 2 — Standing up n8n on the box

This is where most of the unglamorous work lived. n8n needed a public HTTPS endpoint, a process manager, and a reverse proxy. On RunCloud, the sequence that finally worked:

  • Created a web app automate.cricnepal.com with Let’s Encrypt SSL.
  • Switched the stack to “Native NGINX + Custom config.” This was the first trap. The default hybrid stack auto-generates an nginx→apache proxy config whose directives collide with the reverse-proxy I needed — nginx refused to reload with a duplicate error. Native NGINX gave me a clean slate.
  • Added a reverse-proxy config pointing nginx at 127.0.0.1:5678, with the websocket upgrade headers n8n needs for its editor.
  • Ran n8n under Supervisor as the runcloud user, with its environment (host, port, timezone, and the all-important N8N_ENCRYPTION_KEY) set in the supervisor config.

Back up the encryption key. Losing it bricks every stored credential. I wrote it down somewhere safe before I trusted anything to it.

Once the public URL returned n8n’s UI over HTTPS, the infrastructure was done. The rest was inside n8n: a Webhook trigger node, and a Facebook Graph node to post to the page feed.

Step 3 — The authentication detail that cost me an afternoon

Everything was built. The button worked. The webhook fired. n8n received it. And Facebook returned:

(#200) This endpoint requires the 'pages_read_engagement' permission
or the 'Page Public Content Access' feature... requires a page access token.

Here is the trap, stated plainly, because I want to save the next person the afternoon I lost:

Posting to a Page’s /feed requires a Page access token — not a user token, and not a system-user token. They look identical. They’re both long opaque strings. n8n’s credential test will even say “Connection tested successfully” with the wrong one, because the test only does a basic GET, and a basic GET passes for almost any valid token. Only a real Page token can actually post.

There’s a second subtlety on top of it: a Page token inherits the expiry of whatever it was derived from. Derive it from a token that expires in 60 days, and your automation silently dies in 60 days.

The fix, and the reason this is now durable:

  1. I used a system-user token, which is non-expiring.
  2. I derived the Page token from it with a single Graph call:GET /{page-id}?fields=access_token&access_token={system_user_token}The access_token that comes back is a non-expiring Page token — because its source never expires.
  3. I pasted that token into the n8n Facebook credential.

A few myths I had to clear along the way:

  • You do not need App Review to post to a Page you own. Business-owned pages skip it. I lost time chasing a review I didn’t need.
  • The Graph Explorer’s “Get Page Access Token” dropdown won’t always surface business-owned pages — a UI quirk. The ?fields=access_token query works regardless.
  • The credential connecting cleanly is not proof it can post. Test the real action, not the handshake.

After that, the scorer tapped the button and End of Over 10 • BNMC 30/5 appeared on the page. End to end. From a tap inside WordPress to a live post, no human in the middle.

What I’d tell myself before starting

  • Trace the trigger, not the setting. A configured field told me a feature existed. The feature did not exist.
  • Put the words in the codebase. Moving caption composition from the automation tool into PHP made it testable, reviewable, and reproducible. Brittle in-tool formula logic is a liability.
  • Make the integration fail-soft. Scoring a live match must never wait on, or break because of, a social post.
  • For Facebook: page token, derived from a non-expiring source. Frame it on the wall.
  • Gate it with a human. Automation removed the labour, not the judgement. The scorer still decides which over deserves the spotlight.

The feature is small — one button, one post. But it closed the gap between the moment a match turns and the moment our audience sees it. That gap was the whole point.

Built for cricnepal. Stack: WordPress (cn-engine mu-plugin), n8n on RunCloud/Hetzner, Facebook Graph API.