Nischal Tiwari

Replacing make.com with self-hosted n8n — and teaching cricnepal to wish players happy birthday

For a while, cricnepal‘s social posting ran on make.com. Three scenarios, quietly firing webhooks: publish an article → tweet it, score an over → post the summary, hit a milestone → celebrate. It worked. It also sat behind someone else’s dashboard, on someone else’s pricing, with logic I couldn’t version or grep.

So I moved the whole thing onto self-hosted n8n at automate.cricnepal.com. Here’s how it went, what broke, and the small feature that turned out to be the most fun: a daily bot that wishes Nepal cricketers a happy birthday.

Why bother moving

make.com isn’t bad. But cricnepal already runs its own boxes, and every external SaaS in the critical path is one more login, one more bill, one more black box. n8n is open source, self-hostable, and the workflows are just JSON I can export, diff, and back up. For a site that’s mostly mine to maintain, owning the glue felt right.

The migration itself was almost anticlimactic — and that’s the point of doing it well. Every social target in WordPress was already a webhook URL stored as an option. Repointing make.com → n8n was changing a string, not shipping code. The PHP that builds the payload and fires the request never moved.

The stack, briefly

WordPress  →  outbound webhook  →  n8n  →  Facebook (Graph API)
                                      └─→  X (Buffer GraphQL)

n8n receives the webhook, shapes the caption, and fans out to Facebook and X. Facebook goes direct via the Meta Graph API. X goes through Buffer — more on that below.

The birthday bot

The fun part. Cricket fans love an anniversary, and cricnepal has a database of hundreds of Nepal players with dates of birth. So: every morning at 9am Nepal time, find whoever’s celebrating and post a little tribute.

The constraint made it interesting. The obvious version writes stat-packed copy — “1,948 ODI runs, turns 25 today.” But cricnepal’s match data isn’t 100% complete for every domestic player, and the fastest way to lose trust is to confidently post a wrong number. An LLM left to free-wheel would happily invent records.

So the bot is deliberately humble. A small token-gated WordPress endpoint returns only safe bio fields — name, age, playing role, photo, profile link — for Nepal players whose birthday is today. No stats. n8n fills one of four rotating templates so it doesn’t read like a robot saying the same sentence daily:

🎂 Happy 24th birthday to Kushal Bhurtel! 🇳🇵 The top-order batter celebrates today — wishing the Nepal star a brilliant year ahead! 🏏

One nice trick: only about 18% of player records have a featured photo. Instead of posting faceless text, the bot shares the player’s profile page as a link — so Facebook pulls that page’s og:image and every post gets a visual, photo on file or not.

Where it fought back

No migration is clean. A few things cost me real time, so here they are for the next person:

Cloudflare doesn’t like robots. automate.cricnepal.com sits behind a managed challenge. A browser sails through; curl and server-to-server calls get a 403. Cue twenty confused minutes wondering why my test webhook “didn’t exist” — it existed fine, my client just wasn’t a browser. Lesson: test webhooks from the browser, or after publishing, not from a terminal.

TLS to your own origin. From inside the box, the WordPress hostname resolves to a loopback origin serving a Cloudflare Origin certificate — perfectly valid at the edge, but not in Node’s trust store. The fix is a one-toggle “ignore SSL issues” on the internal HTTP call, but the error message (“unable to verify the first certificate”) sends you hunting in the wrong place first.

Buffer changed its API. I wanted X posting to relay through Buffer (free tier, avoids X’s stingy write limits). Buffer’s classic REST API is closed to new apps now — you can’t even get a client id. The new GraphQL API works on the free plan with a personal key, but it’s a different shape: one channel per call, createPost mutation, shareNow mode. Worth knowing before you copy a 2019 tutorial.

The silent JSON gremlin. Importing an n8n workflow from JSON quietly dropped the Facebook node’s query parameters — so it tried to post an empty message and Facebook rejected it. The node looked fine; the values just weren’t there. And a credential rename once landed my text in the HTTP header name field instead of the title, producing the wonderfully cryptic “Header name must be a valid HTTP token.” Both five-minute fixes once you know; both a while to spot.

Where it landed

Six workflows now run in n8n. Publishing an article posts to Facebook and X. Live-match over-summaries and milestones still fire from the scorer. And every morning, if a Nepal cricketer has a birthday, the site says so — on Facebook and X, with a face and a warm line, and not a single invented statistic.

The bigger win isn’t any one workflow. It’s that the glue holding cricnepal’s social presence together is now mine — exportable, diffable, on my own box, and documented well enough that future-me can fix it at 9am when a birthday post throws.

If you’re running a small site on make.com or Zapier and you already own a server, self-hosted n8n is worth the afternoon. Just test your webhooks from a browser. 🇳🇵🏏


Built on WordPress (headless), n8n, the Meta Graph API, and Buffer’s GraphQL API. cricnepal is Nepal’s cricket home — scores, stats, and now, birthday wishes.

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.