Skip to main content

Configuring the Litmus API in GitHub Actions for Automated Email QA

Configure the Litmus API as a GitHub Actions step for automated email rendering checks on every pull request.

Integrating Litmus API into GitHub Actions eliminates manual cross-client rendering checks and enforces strict visual standards in modern transactional pipelines. This guide provides the exact authentication headers, payload structures, and CI/CD workflow syntax required to trigger automated Litmus tests on every pull request. Before implementing the configuration, align this setup with established Email Testing & QA Workflows to maintain deliverability baselines and prevent regression.

GitHub Actions to Litmus API sequence A pull request triggers a runner that reads the secret, posts compiled HTML to Litmus, polls for completion, then reports the check status. GitHub Actions Job Calling the Litmus API Pull Request on: pull_request Runner Step build + read secret LITMUS_API_KEY Litmus API POST /v3/tests Bearer token PR Check pass / fail Poll /v3/tests/{id} with backoff until status = completed, then gate the merge The secret is injected at runtime and masked in runner logs.
A pull request triggers a runner that reads the masked secret, posts compiled HTML to Litmus, polls for completion, then reports a pass or fail check.

Prerequisites and API Authentication Setup

  1. Enable API Access: Log into your Litmus dashboard. Navigate to Account Settings > Integrations & API and generate an API key.
  2. Secure Storage: Add the token to GitHub: Repository Settings > Secrets and variables > Actions > New repository secret.
    • Name: LITMUS_API_KEY
    • Value: [your_litmus_api_key]
  3. Auth Standard: The Litmus REST API v3 requires Authorization: Bearer <token> for all endpoints. Never hardcode credentials or commit tokens to version control. GitHub Actions injects secrets at runtime and masks them in runner logs.

Constructing the GitHub Actions Workflow YAML

Create .github/workflows/email-validation.yml at the repository root. The configuration below compiles your email template, authenticates securely, and posts the payload to the Litmus /v3/tests endpoint.

name: Email Validation Pipeline
on:
  push:
    branches: [main]
  pull_request:
    branches: [develop]

permissions:
  contents: read
  checks: write

jobs:
  litmus-qa:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Build Email HTML
        run: |
          npm ci
          npm run build:email

      - name: Trigger Litmus API Test
        id: trigger
        run: |
          set -e
          # Read compiled HTML; escape for JSON embedding
          HTML_PAYLOAD=$(node -e "process.stdout.write(JSON.stringify(require('fs').readFileSync('dist/email.html','utf8')))")

          RESPONSE=$(curl -s -w "\n%{http_code}" -X POST https://api.litmus.com/v3/tests \
            -H "Authorization: Bearer ${{ secrets.LITMUS_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d "{
              \"test_name\": \"Automated QA Check\",
              \"html_source\": ${HTML_PAYLOAD},
              \"test_type\": \"preview\"
            }")

          HTTP_CODE=$(echo "$RESPONSE" | tail -n 1)
          BODY=$(echo "$RESPONSE" | sed '$d')

          if [ "$HTTP_CODE" -ne 201 ]; then
            echo "::error::Litmus API rejected request. HTTP $HTTP_CODE"
            echo "$BODY"
            exit 1
          fi

          TEST_ID=$(echo "$BODY" | jq -r '.id')
          echo "test_id=$TEST_ID" >> $GITHUB_OUTPUT

Using node -e with JSON.stringify ensures the HTML payload is correctly JSON-escaped before embedding in the request body, avoiding quoting issues with inline sed approaches.

Handling Asynchronous Webhook Callbacks

Litmus processes cross-client rendering asynchronously. Implement a polling loop with exponential backoff to query /v3/tests/{id} until completion. Parse the response and fail the GitHub check if the test reports errors.

      - name: Poll Rendering Status
        run: |
          MAX_RETRIES=25
          RETRY_COUNT=0
          DELAY=10

          while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
            STATUS_RESPONSE=$(curl -s -H "Authorization: Bearer ${{ secrets.LITMUS_API_KEY }}" \
              "https://api.litmus.com/v3/tests/${{ steps.trigger.outputs.test_id }}")

            STATUS=$(echo "$STATUS_RESPONSE" | jq -r '.status')

            if [ "$STATUS" = "completed" ]; then
              echo "Rendering complete."
              break
            elif [ "$STATUS" = "failed" ]; then
              echo "::error::Litmus processing failed. Check payload validity."
              exit 1
            fi

            echo "Status: $STATUS. Waiting ${DELAY}s before retry..."
            sleep $DELAY
            DELAY=$((DELAY * 2))
            RETRY_COUNT=$((RETRY_COUNT + 1))
          done

          if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
            echo "::error::Polling timeout reached. Verify API health."
            exit 1
          fi

      - name: Validate Rendering Results
        run: |
          RESULTS=$(curl -s -H "Authorization: Bearer ${{ secrets.LITMUS_API_KEY }}" \
            "https://api.litmus.com/v3/tests/${{ steps.trigger.outputs.test_id }}")

          # Check if the test completed successfully
          STATUS=$(echo "$RESULTS" | jq -r '.status')
          if [ "$STATUS" != "completed" ]; then
            echo "::error::Test did not complete successfully: $STATUS"
            exit 1
          fi
          echo "All rendering checks passed."

This automated gatekeeping is a cornerstone of robust Litmus & Email on Acid Workflows and ensures broken templates never reach production environments.

Troubleshooting Common Configuration Errors

HTTP Status Root Cause Resolution
401 Unauthorized Expired API key, incorrect scope, or IP allowlist blocking runner IPs. Regenerate key. Verify LITMUS_API_KEY secret length matches dashboard. Disable IP allowlisting for GitHub runner ranges if applicable.
400 Bad Request Malformed JSON, missing required fields, or HTML payload > 5MB. Validate payload with echo $PAYLOAD | jq . before POST. Ensure Content-Type: application/json is explicitly set.
429 Too Many Requests Exceeding Litmus API rate limits during parallel PR checks. Implement exponential backoff (as configured). Serialize workflow runs using concurrency: group: email-qa-${{ github.ref }} in YAML.
jq: command not found Missing JSON parser in runner environment. Add sudo apt-get install -y jq to workflow steps, or use actions/github-script with Node.js JSON.parse().

Debugging Protocol:

  1. Set repository variable ACTIONS_RUNNER_DEBUG=true to enable verbose runner logs.
  2. Add -v to curl commands to inspect exact request/response headers.
  3. Verify workflow permissions: permissions: checks: write is mandatory for GitHub Check API integration.

Production Testing & Validation Steps

  1. Dry Run Locally: Use act or GitHub CLI gh workflow run email-validation.yml --ref <branch> to trigger without merging.
  2. Secret Masking Verification: Add a debug step echo "::add-mask::${{ secrets.LITMUS_API_KEY }}" to confirm GitHub masks the token in logs.
  3. Failure Path Testing: Intentionally inject malformed HTML (e.g., unclosed <table> tags) into your template. Verify the workflow exits with code 1 and posts a failed check status to the PR.
  4. Success Validation: Merge a clean PR. Confirm the GitHub Checks tab shows Email Validation Pipeline as green.

Integrating Litmus API into GitHub Actions standardizes visual QA across distributed teams. By enforcing strict payload validation, implementing exponential backoff, and gating merges on rendering results, you eliminate manual review overhead and guarantee production-ready email templates. Once rendering checks are stable, extend the same job to score deliverability by automating Litmus spam testing in CI, so authentication and content-filter regressions fail the build alongside layout breaks.

Secrets handling and least-privilege configuration

The LITMUS_API_KEY secret should be scoped to exactly the repositories that need it. Prefer an organization-level secret with an explicit repository allowlist over copying the same token into every repo — when the token rotates, you update it once. Two practices keep the credential safe across forked-PR and reusable-workflow scenarios:

# Limit the job's GITHUB_TOKEN to the minimum; the Litmus key is a separate repo secret.
permissions:
  contents: read    # checkout only
  checks: write     # post the pass/fail check back to the PR

jobs:
  litmus-qa:
    # Pull requests from forks do NOT receive secrets by default — guard the job so it
    # only runs where LITMUS_API_KEY is actually injected, avoiding confusing 401s.
    if: github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    environment: email-qa   # optional: gate the secret behind an environment with reviewers

Never echo the raw key. GitHub auto-masks registered secrets, but a value derived from the secret (for example, a base64 of user:key) is not masked unless you register it explicitly with echo "::add-mask::$DERIVED". Treat any transformed credential as a new secret that needs masking before it appears in a command that might log.

Serializing concurrent pull requests

Several open pull requests can each trigger the workflow at once and blow past the Litmus concurrency cap, producing a wave of 429 responses. A concurrency group serializes runs per branch and cancels superseded ones so only the latest commit consumes render minutes:

concurrency:
  group: email-qa-${{ github.ref }}   # one in-flight run per branch
  cancel-in-progress: true            # a new push cancels the older, now-stale run

For account-wide throttling across different branches, combine this with the exponential backoff already in the polling step — the group handles same-branch churn, backoff handles cross-branch contention against the API's global limit.

Variant cases

Email on Acid instead of Litmus. The workflow shape is identical: build, submit, poll, gate. Only three things change — the base URL, the auth header (Email on Acid historically uses an API key via Basic auth or a query parameter rather than a Bearer token), and the JSON field names you read for status and per-client results. Keep the submit/poll/gate logic in a single script and select the provider behind one environment variable so the YAML never changes.

Matrix across multiple templates. When a repo ships several templates, fan out with a job matrix so each renders in parallel under its own check, while a shared concurrency group still caps total in-flight API calls:

strategy:
  fail-fast: false                 # one broken template shouldn't hide the others
  matrix:
    template: [welcome, receipt, password-reset]
steps:
  - name: Submit ${{ matrix.template }}
    run: node scripts/run-render-test.mjs "dist/${{ matrix.template }}.html"

Scheduled full-matrix run. Pull-request checks should stay fast with a small client list — running thirty clients on every push wastes render minutes and slows feedback. Keep the pull-request job to the five or six clients that receive most of your mail, then add a nightly on: schedule trigger that runs the full client matrix so legacy Outlook builds and regional providers are still covered without taxing every PR. A scheduled failure pages whoever owns deliverability rather than blocking an unrelated merge, which keeps the fast gate fast and the broad gate thorough.

on:
  schedule:
    - cron: '0 6 * * *'   # 06:00 UTC nightly full-client sweep

Validation checklist

  • LITMUS_API_KEY is stored as a repo or org secret, never committed or echoed raw.
  • Any value derived from the secret is registered with ::add-mask:: before use.
  • The job is guarded so it doesn't run (and 401) on fork PRs that lack secrets.
  • permissions is least-privilege: contents: read, checks: write.
  • A concurrency group with cancel-in-progress serializes same-branch runs.
  • The polling step has a hard retry ceiling so a stuck render fails rather than hangs.
  • A malformed-HTML test confirms the job exits non-zero and posts a failed check.
  • A nightly scheduled run covers the full client matrix beyond the PR subset.

Treated this way, the Litmus step stops being an after-the-fact spot check and becomes a true deploy gate: a render regression, a broken <style> block, or a failed authentication check fails the pull request in CI before the template can ever reach a recipient's inbox, which is precisely where rendering bugs are most expensive to discover.


← Back to Litmus & Email on Acid Workflows