Post new app build links to Slack and Discord automatically
· 6 min read
A new test build is only useful once the people testing it know it exists. Posting the link by hand in a team channel works until someone forgets, or posts the wrong build. There are two ways to automate it: let AppForShare post each build to Slack or Discord for you, or post the message yourself from CI. This article walks through both and when each one makes sense.
Option 1: AppForShare webhooks
Integrations are part of the Pro and Team plans. You add them under Dashboard, Integrations, and there are three kinds:
- Slack: a Slack incoming webhook URL, which starts with
https://hooks.slack.com/services/. - Discord: a Discord channel webhook URL, which starts with
https://discord.com/api/webhooks/. - Webhook: any https endpoint of your own, which receives signed JSON.
Each integration has a name (for example #releases), can cover all projects or a single one, and subscribes to the events you tick. The Send test button delivers a ping event so you can check the channel before the next real build.
Events you can subscribe to
| Event | When it fires |
|---|---|
build.ready | A build finished processing and its install link works |
build.failed | An upload could not be turned into a build |
build.installed | Someone started an install (device, OS version, model, country) |
bandwidth.warning | Monthly bandwidth reached 80% or 100% |
store.status | A release changed state on a connected App Store Connect or Google Play account |
store.review | A new customer review arrived on a connected app |
New integrations start with build.ready and build.failed ticked, which is what most release channels want. Installs can be noisy on a busy project, so many teams send build.installed to a separate channel or leave it off.
What the message looks like
For Slack and Discord you do not write any formatting. The message shows the app icon, version, platform and size, the release notes, and a button to the install page. For installs it shows the device, OS and country instead. Slack limits incoming webhooks to about one message per second, so on a busy hook messages wait their turn rather than being dropped.
Setting it up in Slack
- In Slack, create an app for your workspace (or use an existing one), turn on Incoming Webhooks and add a webhook to the channel you want.
- Copy the URL. Treat it like a password: anyone who has it can post to that channel. AppForShare only shows a short hint of it after you save.
- In AppForShare, open Dashboard, Integrations, choose Slack, paste the URL, pick the project and events, and add it.
- Press Send test.
Setting it up in Discord
- In the Discord channel settings, open Integrations, then Webhooks, and create a webhook.
- Copy the webhook URL.
- Add it in AppForShare as a Discord integration and send a test.
Generic signed webhooks
For anything that is not Slack or Discord, such as an internal bot, Microsoft Teams through your own relay, or a status dashboard, use a generic webhook. Each request is a JSON POST with these headers:
X-AppForShare-Event: build.ready
X-AppForShare-Delivery: 1842
X-AppForShare-Signature: t=1791850000,v1=5f0c...e91a
The body has an id, the event, createdAt and a data object. For build.ready that includes the build's name, platform, bundle id, version, size, installUrl, iconUrl and release notes. The full example is on the webhooks docs page.
The signature is HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with the signing secret shown when you add the webhook. Verify it before trusting the payload:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const got = Buffer.from(parts.v1 ?? '', 'hex');
return got.length === 32 && timingSafeEqual(got, Buffer.from(expected, 'hex'));
}
Two details matter here. Use the raw body exactly as received, before any JSON parsing, or the hash will not match. And reject old timestamps, which stops someone from replaying a captured request.
Delivery rules worth knowing
- Answer with any 2xx status within 10 seconds. Redirects are not followed.
- Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. A 404 or 410 answer stops retries for that delivery.
- A retry sends the same body with the same event
id, so store ids you have handled and skip duplicates. - After 15 failures in a row, the webhook is switched off and you get an email.
- Only https URLs on port 443 or 8443 that resolve to public addresses are accepted.
A common pattern for a slow handler: verify the signature, save the event, answer 200 at once, and do the real work in the background.
Option 2: post from CI yourself
If you upload builds from a pipeline, you can post the message in the same job. The AppForShare CLI prints the result as JSON with --json, so you can build any message you like:
npx --yes appforshare@1 upload app-release.apk --notes "$NOTES" --json > afs.json
URL=$(node -p "require('./afs.json').url")
VERSION=$(node -p "require('./afs.json').version")
curl -sS -X POST -H 'Content-type: application/json' \
--data "$(node -p "JSON.stringify({text: 'Android build ' + process.argv[1] + ' is ready: ' + process.argv[2]})" "$VERSION" "$URL")" \
"$SLACK_WEBHOOK_URL"
Building the JSON with JSON.stringify instead of string pasting matters: release notes and commit messages often contain quotes and line breaks that break hand written JSON. For Discord, the same request works with a content field instead of text, sent to the Discord webhook URL. Keep both URLs in CI secrets.
The JSON output contains url, slug, name, version and expiresAt. More pipeline examples are in uploading builds from CI.
Which one to use
| Need | Webhooks | Post from CI |
|---|---|---|
| Builds uploaded by hand as well as from CI | Covered, every build fires the event | Only builds from that pipeline |
| Message format | Fixed, with icon, version, notes and install button | Anything you write |
| Failed uploads, installs, bandwidth, store status, reviews | Separate events you can subscribe to | Only what the job knows |
| Per project channels | One integration per project | One secret per pipeline |
| Plan | Pro and Team | Pro and Team for CI tokens; posting itself is your own code |
| Extra code to maintain | None for Slack and Discord | A script per pipeline |
Many teams use both: a webhook for the shared release channel, and a CI message for something only the pipeline knows, such as the branch, the pull request or the person who merged.
Tips for a channel people actually read
- Write release notes for testers: what changed and what to test. They appear in the message, so "fix" alone tells nobody anything. Test build distribution best practices has more on this.
- Send
build.failedto the people who can fix it, not to the whole QA channel. - Use one integration per project if several apps share a workspace, so each team sees only its builds.
- Store release status and review events are useful in a channel for the people who handle store releases. They need a connected App Store Connect or Google Play account.
Not everyone lives in Slack. People who turn on notifications in the AppForShare web app also get a push notification for new builds in their team, next to the usual emails.
Connect your channel
Webhooks for Slack, Discord and your own endpoints are included in Pro at $12 a month and Team at $39 a month, together with CI upload tokens and release notes. Set them up under Dashboard, Integrations, check the webhooks reference for payload details, or compare plans on the pricing page. New here? Create an account first.


