I Automated My Free Coffee

Every week, Octopus Energy gives Octoplus members a free coffee from Caffe Nero or Greggs. New codes drop daily before the coffee shops open, and are gone within minutes. Claim one and you're sorted for the week. I got my AI agent to grab mine.


The Problem

The Octopus app shows a "Claim" button. You tap it, get a QR code, get coffee. Simple -- unless you're still in bed when the codes drop, which is most people.

Codes appear around 5am each day and are typically gone by 5:30. Miss the window and you're trying again tomorrow. Once you claim one, you're done for the week -- it resets at midnight a week later.

I got a few by setting alarms. Waking up at 4:50am, refreshing the page, hammering the claim button. It worked, but doing it every morning until you finally snag one gets old fast.

The obvious solution: make a computer do it.


The First Attempt (Browser Automation)

I run a self-hosted AI agent called OpenClaw on my homelab. It lives on a Proxmox LXC container, handles WhatsApp messages, monitors services, and reboots crashed containers when Uptime Kuma fires alerts. It already had headless Chromium installed for other automations, so browser automation was the obvious first approach.

Four daily cron jobs: 5:05am, 5:15am, 5:30am (three claim attempts), plus an 8am summary if they all failed. Each spawned a headless browser, navigated to the Octoplus page, clicked the claim button, and tried to screenshot the QR code.

It worked. Sometimes. When the session cookies hadn't expired. When the page layout hadn't changed. When Chromium didn't decide it needed 500MB of RAM to click a single button.

Fragile doesn't begin to cover it. Then I found the API.


Finding the API

Opening Chrome DevTools on the Octopus rewards dashboard, I filtered network requests by graphql. The app talks to two endpoints:

  • https://api.octopus.energy/v1/graphql/ -- the public API, used for authentication
  • https://api.backend.octopus.energy/v1/graphql/ -- an internal endpoint the web app uses for everything else

Three operations. That's all you need.


Authentication

Octopus uses their Kraken platform for auth. Getting a token is a standard GraphQL mutation against the public endpoint:

mutation ObtainKrakenToken($input: ObtainJSONWebTokenInput!) {
  obtainKrakenToken(input: $input) {
    token
    refreshToken
    refreshExpiresIn
  }
}

Pass your Octopus Energy email and password as the input. Tokens last 60 minutes, refresh tokens last 7 days. The script re-authenticates every 50 minutes to stay ahead of expiry.

One gotcha that cost me an hour: the public endpoint accepts Authorization: JWT <token>. But the backend endpoint wants just the raw token -- no JWT prefix, no Bearer prefix. Every other combination returns a 401.

# Public endpoint (auth)
headers = {"Authorization": f"JWT {token}"}   # Works

# Backend endpoint (offers, claims)
headers = {"Authorization": token}             # Works
headers = {"Authorization": f"JWT {token}"}    # 401
headers = {"Authorization": f"Bearer {token}"} # 401

Checking Availability

Poll the backend endpoint with your account number. Your account number is in the Octopus dashboard URL -- the A-XXXXXXXX bit in the address bar.

query CheckOffers($accountNumber: String!) {
  octoplusOfferGroups(accountNumber: $accountNumber, first: 20) {
    edges {
      node {
        octoplusOffers {
          slug
          claimAbility {
            canClaimOffer
            cannotClaimReason
          }
        }
      }
    }
  }
}

The claimAbility field tells you everything:

  • canClaimOffer: true -- stock available, claim now
  • OUT_OF_STOCK -- codes haven't dropped yet, keep polling
  • MAX_CLAIMS_PER_PERIOD_REACHED -- already claimed this week, exit

Claiming the Reward

When canClaimOffer flips to true, a single mutation does the job:

mutation ClaimOctoplusReward($accountNumber: String!, $offerSlug: String!) {
  claimOctoplusReward(accountNumber: $accountNumber, offerSlug: $offerSlug) {
    rewardId
  }
}

Where offerSlug is "caffe-nero" or "greggs". The reward appears in the Octopus app immediately with a scannable QR code.

Note: this mutation is marked as deprecated as of March 2026, with removal scheduled for August 2026. The web app still uses it for now.


The Script

Around 200 lines of Python ties it all together:

  1. Pull credentials from ClosedClaw -- an encrypted credential vault for OpenClaw that I forked and improved. It uses AES-256-GCM encryption with scrypt key derivation, no daemon or HTTP server, just a CLI tool. The script calls closedclaw get at runtime to decrypt the Octopus email, password, and account number on demand. Nothing stored in plaintext.
  2. Authenticate via obtainKrakenToken and cache the token
  3. Poll octoplusOfferGroups every 60 seconds with +/-15s random jitter
  4. Claim the instant stock appears for caffe-nero (fallback: greggs)
  5. Screenshot the QR code page with a headless browser
  6. Send the screenshot to my phone via WhatsApp
  7. Exit cleanly

The core polling loop:

while True:
    offers = check_offers(auth, account_number)
    offer = find_offer(offers, "caffe-nero")

    if offer["claimAbility"]["canClaimOffer"]:
        reward_id = claim_offer(auth, account_number, "caffe-nero")
        screenshot_qr_and_notify(reward_id)
        sys.exit(0)
    elif offer["claimAbility"]["cannotClaimReason"] == "MAX_CLAIMS_PER_PERIOD_REACHED":
        log("Already claimed this week.")
        sys.exit(0)

    time.sleep(max(10, 60 + random.uniform(-15, 15)))

If this week's code has already been claimed, it detects MAX_CLAIMS_PER_PERIOD_REACHED and exits immediately. No wasted resources.


The OpenClaw Bit

Instead of running this in tmux or as a bare cron job, the script runs through OpenClaw as a scheduled task:

openclaw cron add \
  --name "octocoffee" \
  --cron "0 0 * * *" \
  --tz "Europe/London" \
  --timeout-seconds 28800

Every day at midnight, the AI agent spawns, runs the script, and handles whatever happens. If this week's code has already been claimed, the script detects it on the first poll and exits silently -- no wasted resources. Otherwise it polls until codes drop around 5am, claims one, and sends me a WhatsApp message.

The notification is the best part. After claiming via the API, OpenClaw's headless browser navigates to the specific reward page on the Octopus dashboard, screenshots the QR code, and sends it straight to me on WhatsApp. I wake up, check my phone, and there's my coffee code with the barcode ready to scan at Nero.

It replaced four fragile browser cron jobs with one script that just works.


Results

First successful API run -- claimed at 05:02am after polling since midnight:

[04:59:56] caffe-nero: OUT_OF_STOCK (poll #298)
[05:00:50] caffe-nero: OUT_OF_STOCK (poll #299)
[05:01:58] caffe-nero: OUT_OF_STOCK (poll #300)
[05:02:45] Refreshing token...
[05:02:45] Token refreshed.
[05:02:45] Claiming caffe-nero...
[05:02:46] Claimed caffe-nero! Reward ID: xxxxxxxx
[05:02:46] Taking screenshot of rewards page...
[05:02:52] Screenshot sent via WhatsApp.

A WhatsApp message with the QR code screenshot lands on my phone. The reward is in the Octopus app, ready to scan. Free coffee, zero effort.


Try It Yourself

You don't need OpenClaw or a homelab to do this. The core is just three API calls. Here's the minimum viable coffee claimer with curl.

Step 1: Authenticate

Hit the public endpoint with your Octopus Energy credentials to get a JWT token:

TOKEN=$(curl -s -X POST \
  https://api.octopus.energy/v1/graphql/ \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation {
      obtainKrakenToken(input: {
        email: \"YOUR_EMAIL\",
        password: \"YOUR_PASSWORD\"
      }) { token }
    }"
  }' | jq -r '.data.obtainKrakenToken.token')

Step 2: Check offers

Poll the backend endpoint with your account number. Remember: raw token in the Authorization header, no Bearer or JWT prefix.

curl -s -X POST \
  https://api.backend.octopus.energy/v1/graphql/ \
  -H "Content-Type: application/json" \
  -H "Authorization: $TOKEN" \
  -d '{
    "query": "query {
      octoplusOfferGroups(
        accountNumber: \"A-XXXXXXXX\",
        first: 20
      ) {
        edges {
          node {
            octoplusOffers {
              slug
              claimAbility {
                canClaimOffer
                cannotClaimReason
              }
            }
          }
        }
      }
    }"
  }' | jq '.data.octoplusOfferGroups.edges[].node.octoplusOffers[]
         | select(.slug == "caffe-nero")'

Step 3: Claim

When canClaimOffer is true, fire the claim mutation:

curl -s -X POST \
  https://api.backend.octopus.energy/v1/graphql/ \
  -H "Content-Type: application/json" \
  -H "Authorization: $TOKEN" \
  -d '{
    "query": "mutation {
      claimOctoplusReward(
        accountNumber: \"A-XXXXXXXX\",
        offerSlug: \"caffe-nero\"
      ) { rewardId }
    }"
  }' | jq '.data.claimOctoplusReward.rewardId'

Wrap step 2 in a loop with a sleep, and you've got yourself a coffee bot. A Raspberry Pi, a VPS, a GitHub Actions workflow -- anything that can run a cron job will do.


The Expiry Date

Octopus marked the claimOctoplusReward mutation as deprecated in March 2026, with removal planned for August 2026. When the API goes away, it's back to browser automation. But at least by then I'll have had five months of effortless free coffee.

Free Nero, zero effort, every week. Until August, at least.