yeetpostyeetpost

A safe CI for social posting: the sandbox and test keys

August 30, 2026

Every other API in this category gives you one option for testing your posting code: post for real, or mock us. Posting for real means your LinkedIn is a build log. Mocking us means you are testing your mock, which passes right up until the day the real API changes shape.

So yeetpost has a third option, and this post is how to wire it into CI end to end.

#Sandbox connections

A sandbox connection is a real connection that publishes nothing. It takes posts, validates them the way a platform would, stores them, returns the same shapes a real platform returns, and fires the same signed webhooks. Then it drops the post on the floor.

Add one in the dashboard under "New connection". It gets the slug sandbox, and a second one gets sandbox_2. Sandbox connections are free: they never count toward your plan's connections and never change what you are billed, so having one per project costs nothing.

The one-liner endpoint takes a plain text body, which makes it the fastest way to prove your key works:

curl -X POST https://api.yeetpost.com/api/v2/post/sandbox \ -H "x-api-key: $YEETPOST_TEST_KEY" \ -d 'does my pipeline work'
{ "platform": "sandbox", "link": "https://app.yeetpost.com/sandbox/6f1b0c8a-1f3d-4d3e-9a2b-2f5a1c9e7d10" }

That link opens the stored post in the dashboard. Nothing went anywhere near a timeline.

#Test keys

A sandbox connection on its own is not enough, because a typo in a slug still reaches a real account. That is what test keys are for.

Create one in settings. Test keys start yp_test_ instead of yp_secret_, and the mode is a property of the key, so the whole API narrows around it:

The MCP server scopes a test key the same way, so an agent given a test key sees a world containing nothing but sandboxes. A misrouted post in CI is not a scary incident, it is a 400.

It works the other way too, which is the part people miss: a live key can post to a sandbox connection. So you can smoke test the real credentials end to end without publishing. That counts as a live post, and it wakes your live webhooks.

#Testing the error path

Half of what CI should be checking is what happens when a platform says no. You cannot make LinkedIn refuse a post on demand, so the sandbox will do it for you: start the text with [fail].

curl -X POST https://api.yeetpost.com/api/v2/post/sandbox \ -H "x-api-key: $YEETPOST_TEST_KEY" \ -d '[fail] does my error path work'
{ "error": "platform_rejected", "message": "sandbox failure requested", "req_id": "req_43219876" }

The post is stored with that error, and a post.failed webhook fires. Over POST /posts the same text comes back as a per-item failure inside a 200, which is the shape your code actually has to handle:

{ "results": [ { "connectionSlug": "sandbox", "status": "failed", "postId": "0b7e4a52-9f0c-4a1c-8b3a-6d2f5b1c0e44", "url": null, "scheduledFor": null, "threadCount": 0, "hasFirstComment": false, "error": { "code": "platform_rejected", "message": "sandbox failure requested" } } ] }

Two sandbox connections and one [fail] prefix gives you the partial-failure case: one item sent, one item failed, HTTP 200. That is the case most integrations get wrong, and now you can assert on it.

#Webhooks have a mode too

A webhook belongs to the mode of the key that created it, and it hears about posts made in that mode only. Create your webhook with a test key and it is a test webhook: it receives sandbox events and nothing else, so a run of your test suite can never wake your production receiver. A live key does not see, delete or read the deliveries of a test webhook (those ids answer 404), nor the other way round.

Point the test webhook at your staging receiver, create it with the test key, and leave the live one alone.

Every delivery also carries an x-yeetpost-mode header, test or live, alongside x-yeetpost-event, x-yeetpost-event-id and the x-yeetpost-signature HMAC. Your receiver can branch on that header instead of guessing which environment woke it.

#The GitHub Actions job

Now put it together: every pull request exercises the real posting code against the sandbox, and a tag actually publishes. One workflow, one script, the only difference is which secret and which slugs the step gets.

name: announce on: pull_request: push: tags: ["v*"] jobs: announce: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Post the release note env: YEETPOST_API_KEY: ${{ github.ref_type == 'tag' && secrets.YEETPOST_LIVE_KEY || secrets.YEETPOST_TEST_KEY }} CONNECTION_SLUGS: ${{ github.ref_type == 'tag' && '["linkedin","x"]' || '["sandbox"]' }} TEXT: "shipped ${{ github.ref_name }}: per-connection results, sandbox test keys, five MCP tools" run: | body=$(jq -nc \ --arg text "$TEXT" \ --argjson slugs "$CONNECTION_SLUGS" \ '{text: $text, connectionSlugs: $slugs}') response=$(curl -sS -X POST https://api.yeetpost.com/api/v2/posts \ -H "x-api-key: $YEETPOST_API_KEY" \ -H "content-type: application/json" \ -H "Idempotency-Key: announce-${{ github.sha }}" \ -d "$body") echo "$response" | jq . failures=$(echo "$response" | jq '[.results[] | select(.status == "failed")] | length') if [ "$failures" -ne 0 ]; then echo "::error::$failures connection(s) refused the post" exit 1 fi

Three things are doing work there.

github.ref_type is tag on a tag push and branch on a pull request, so one expression picks the key and another picks the slugs. Put YEETPOST_TEST_KEY and YEETPOST_LIVE_KEY in repository secrets, and put the live key in a protected environment if you want a human in the loop before anything publishes.

Idempotency-Key makes a re-run safe. Same key with the same body replays the stored response verbatim and posts nothing again. Same key with a different body is a 409 idempotency_key_conflict, and a second request racing the first is also a 409, so two retries cannot both post. Keys live 24 hours, which comfortably covers "the job failed on the notify step and I hit re-run". Keying it on the commit sha means the same commit announced twice is a replay, not a duplicate.

The failures check is the one line most integrations skip. POST /posts fans out, and one connection failing does not fail the request: you get a 200 with a failed item. If you only check the HTTP status, a dead LinkedIn token is a green build forever. Read the per-item status.

On a pull request this whole thing runs against sandbox. The JSON in the log is the JSON production will get. Prefix a PR title with [fail] in a throwaway branch and you can watch the job fail on purpose.

#What does not work yet

The sandbox mimics shapes, not platforms. It will not tell you that your text is too long for X specifically, that a Mastodon instance has a 500 character limit, or that LinkedIn dislikes your link. It accepts threads and images and first comments and reports them back, but it is not simulating each platform's rules, so a post that passes the sandbox can still be refused live for a platform-specific reason. [fail] is the only failure it can be asked for: there is no way to request a specific error code, a timeout or a rate limit. There is no way to seed sandbox history, so a test that wants ten past posts has to create them. And sandbox connections are per account rather than per environment, so parallel CI jobs sharing one key share one sandbox's post list. Give each project its own.

#Start here

Add a sandbox connection, create a test key, and run the first curl in this post. If it returns a link, your CI never has to post to LinkedIn by accident again. The API docs have the rest.