By now, backing up services via GitHub Actions has become a bit of a habit. There’s already one for faktury-online.com, one for Bitwarden, and one for Cloudflare. This time it is Resend, the email sending service I use for transactional emails.
The idea is always the same: hit the API, dump everything to JSON files, and commit the result into a private repository on a schedule. What gets backed up this time:
- domains
- API keys (names and IDs, not the actual secret values)
- audiences and contacts per audience
- contacts (flat list)
- webhooks
- broadcasts
- templates
- topics
- contact properties
- segments and contacts per segment
The workflow itself stays clean because all the logic lives in backup.sh:
name: Resend Backup
on:
workflow_dispatch:
schedule:
- cron: "20 3 * * 0"
jobs:
backup:
runs-on: ubuntu-24.04
permissions:
contents: write
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
steps:
- uses: actions/checkout@v6
- run: ./backup.sh
- uses: stefanzweifel/git-auto-commit-action@v7
The script handles cursor-based pagination, so it fetches everything regardless of how many items you have. The key part is a small helper function that loops until there are no more pages:
fetch_all() {
local endpoint="$1"
local results="[]"
local after=""
while true; do
if [ -n "$after" ]; then
query="?limit=100&after=$after"
else
query="?limit=100"
fi
response=$(curl -s -X GET "https://api.resend.com${endpoint}${query}" \
-H "Authorization: Bearer $RESEND_API_KEY" \
-H "Content-Type: application/json")
data=$(echo "$response" | jq '.data // []')
results=$(printf '%s\n%s' "$results" "$data" | jq -s 'add')
has_more=$(echo "$response" | jq -r '.has_more // false')
[ "$has_more" != "true" ] && break
after=$(echo "$response" | jq -r '.data[-1].id')
done
echo "$results"
}
All the resources then follow the same pattern. Audiences and segments get their contacts fetched individually in a loop:
audiences=$(fetch_all "/audiences")
echo "$audiences" | jq > audiences.json
audience_ids=$(echo "$audiences" | jq -r '.[].id // empty')
for audience_id in $audience_ids; do
fetch_all "/audiences/$audience_id/contacts" | jq > "contacts/$audience_id.json"
done
Instructions #
- Obtain a Resend API key
- Add it to your Github repository as a secret named
RESEND_API_KEY - Enable Github Actions write permissions, as this creates automatic commits
- The backup runs every Sunday early in the morning, adjust
schedulein.github/workflows/main.ymlif needed
Resend API key #
One caveat worth mentioning: Resend does not offer read-only API key scoping. Every key gets full access. The workflow only ever does GET requests, but the key itself is technically capable of more. Worth keeping in mind when storing it.
To create a Resend API key:
- Log in at https://resend.com/
- In the left sidebar, click “API Keys”
- Click “Create API Key”
- Set a descriptive name like “Backup”
- Leave permission as “Full access” — no read-only option exists
- Optionally restrict to a specific domain
- Click “Add” and copy the key immediately (shown only once)
Adding Github Secrets #
- Go to your Github repository → “Settings”
- In the left sidebar: “Secrets and variables” → “Actions”
- Click “New repository secret”:
- Name:
RESEND_API_KEY - Value: your Resend API key
- Click “Add secret”
Enabling Github Actions Write Permissions #
- Go to your Github repository → “Settings”
- “Actions” under “Code and automation” → “Workflow permissions”
- Select “Read and write permissions”
- Click “Save”
Local development #
Install nektos/act:
brew install act
Obtain a fine-grained Personal Access Token, then:
cp .secrets.example .secrets
Fill in both RESEND_API_KEY and GITHUB_TOKEN, then run:
act workflow_dispatch --no-skip-checkout
Prefer the Medium Docker image (500 MB) if this is your first time
running act.
Creating a Fine-Grained Personal Access Token #
- Github → profile picture → “Settings” → “Developer settings”
- “Personal access tokens” → “Fine-grained tokens” → “Generate new token”
- Give it a name, set expiration, select this repository under “Repository access”
- Under “Repository permissions”, set “Contents” to “Read and write”, leave everything else as “No access”
- Generate and copy immediately
Note that GITHUB_TOKEN is automatically injected when running on Github.
You only need it locally for act. Enjoy!
Links #
- https://resend.com/docs/api-reference/introduction
- https://resend.com/docs/api-reference/pagination
- https://github.com/stefanzweifel/git-auto-commit-action#usage
- https://nektosact.com/usage/runners.html#runners
- https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens