Sync Data Between Two APIs on a Schedule

Many workflows require keeping two systems in sync: pulling records from a source API and pushing them to a destination, on a reliable schedule. This recipe sets up a lightweight ETL script that runs on your server, handles errors gracefully, and reports back to CronJobPro so you know each run actually completed. Whether you are syncing CRM contacts to a marketing tool, pushing orders to a fulfillment API, or mirroring product data between platforms, the pattern is the same.

Schedule

0 * * * *

Every hour, at the top of the hour

Setup

  1. 1

    Create a heartbeat monitor in CronJobPro

    In your CronJobPro dashboard, add a new Heartbeat monitor. Set the period to 1 hour and the grace period to 5 minutes. Copy the unique ping URL you receive, for example https://cronjobpro.com/ping/abc123. This URL is what your script will call on a successful run.

  2. 2

    Store credentials and the ping URL as environment variables

    On your server, add SOURCE_API_KEY, DEST_API_KEY, and HEARTBEAT_URL to a secure environment file such as /etc/etl-sync.env or your application's .env file. Never hardcode secrets in the script itself. Load them with: set -a; source /etc/etl-sync.env; set +a

  3. 3

    Save the sync script to your server

    Copy the script below to a path such as /usr/local/bin/api-sync.sh and make it executable with: chmod +x /usr/local/bin/api-sync.sh. Review the SOURCE_URL and DEST_URL variables at the top and replace them with your actual API endpoints.

  4. 4

    Add the cron entry

    Open your crontab with crontab -e and add the following line, replacing the path if needed: 0 * * * * /bin/bash -c 'set -a; source /etc/etl-sync.env; set +a; /usr/local/bin/api-sync.sh >> /var/log/api-sync.log 2>&1' This runs the job every hour and appends all output to a log file for debugging.

  5. 5

    Test the first run manually

    Run the script once by hand: source /etc/etl-sync.env && /usr/local/bin/api-sync.sh. Check the output for errors and verify that your destination API received the data. Also confirm that a ping arrived in your CronJobPro heartbeat monitor dashboard, showing the monitor as healthy.

The script

bash

#!/usr/bin/env bash
set -euo pipefail

# --- Configuration (loaded from environment) ---
SOURCE_URL="https://source-api.example.com/v1/records"
DEST_URL="https://dest-api.example.com/v1/ingest"
SOURCE_API_KEY="${SOURCE_API_KEY}"
DEST_API_KEY="${DEST_API_KEY}"
HEARTBEAT_URL="${HEARTBEAT_URL}"  # https://cronjobpro.com/ping/<token>

LOG_PREFIX="[api-sync $(date -u '+%Y-%m-%dT%H:%M:%SZ')]"

cleanup() {
  local exit_code=$?
  if [ $exit_code -ne 0 ]; then
    echo "$LOG_PREFIX ERROR: script exited with code $exit_code"
    # Report failure to CronJobPro so the heartbeat is not counted as a miss
    curl -fsS --retry 3 "${HEARTBEAT_URL}/fail" -o /dev/null || true
    # Also report the exact exit code
    curl -fsS --retry 3 "${HEARTBEAT_URL}/exitcode/${exit_code}" -o /dev/null || true
  fi
}
trap cleanup EXIT

echo "$LOG_PREFIX Starting ETL sync"

# --- Step 1: Fetch data from source API ---
echo "$LOG_PREFIX Fetching from source..."
RAW_RESPONSE=$(
  curl -fsS --retry 3 --max-time 30 \
    -H "Authorization: Bearer ${SOURCE_API_KEY}" \
    -H "Accept: application/json" \
    "${SOURCE_URL}"
)

# Validate that we got a non-empty JSON array or object
if [ -z "$RAW_RESPONSE" ]; then
  echo "$LOG_PREFIX ERROR: empty response from source API"
  exit 1
fi

echo "$LOG_PREFIX Received $(echo "$RAW_RESPONSE" | wc -c) bytes from source"

# --- Step 2: Transform (optional — pipe through jq if needed) ---
# Example: extract only active records and rename a field
TRANSFORMED=$(
  echo "$RAW_RESPONSE" | jq '[.[] | select(.status == "active") | {id: .id, name: .full_name, email: .email_address}]'
)

RECORD_COUNT=$(echo "$TRANSFORMED" | jq 'length')
echo "$LOG_PREFIX Transformed ${RECORD_COUNT} records"

if [ "$RECORD_COUNT" -eq 0 ]; then
  echo "$LOG_PREFIX No records to sync, exiting cleanly"
  # Still ping success — zero records is a valid outcome
  curl -fsS --retry 3 "${HEARTBEAT_URL}" -o /dev/null
  exit 0
fi

# --- Step 3: Push data to destination API ---
echo "$LOG_PREFIX Pushing to destination..."
DEST_RESPONSE=$(
  curl -fsS --retry 3 --max-time 60 \
    -X POST \
    -H "Authorization: Bearer ${DEST_API_KEY}" \
    -H "Content-Type: application/json" \
    -d "$TRANSFORMED" \
    "${DEST_URL}"
)

# Check that the destination acknowledged the payload
ACCEPTED=$(echo "$DEST_RESPONSE" | jq -r '.status // "unknown"')
echo "$LOG_PREFIX Destination responded: ${ACCEPTED}"

if [ "$ACCEPTED" != "ok" ] && [ "$ACCEPTED" != "accepted" ]; then
  echo "$LOG_PREFIX WARNING: unexpected destination status '${ACCEPTED}'"
  # Treat unexpected statuses as failure
  exit 2
fi

echo "$LOG_PREFIX ETL sync completed successfully (${RECORD_COUNT} records)"

# --- Step 4: Ping CronJobPro heartbeat on success ---
curl -fsS --retry 3 "${HEARTBEAT_URL}" -o /dev/null
echo "$LOG_PREFIX Heartbeat pinged"

Monitor it

Create a Heartbeat monitor in CronJobPro for this job, set the period to 1 hour and the grace period to 5 minutes. The script calls https://cronjobpro.com/ping/your-token at the very end, after all data has been fetched, transformed, and confirmed accepted by the destination. If the script crashes or exits early at any point, the trap handler fires instead and calls /ping/your-token/fail along with /ping/your-token/exitcode/N so you see not just that a ping was missed, but exactly what went wrong. If a full hour plus 5 minutes passes without any ping, CronJobPro alerts you by email, Slack, Discord, Teams, PagerDuty, Opsgenie, or webhook — whichever channels you configure in the monitor settings. This means you are alerted both when cron never fires at all (no ping) and when the script runs but fails partway through (fail or exitcode ping). Check the monitor timeline in the dashboard to see every success and failure alongside its timestamp.

Frequently asked questions

What happens if the source API returns zero records?

The script exits cleanly and still pings the heartbeat as a success, because zero records is a valid outcome — no data changed since the last run. Only a non-zero exit code or a curl failure will trigger the failure path.

Can I run this more frequently than once an hour?

Yes. Change the cron expression to something like */15 * * * * for every 15 minutes, and update the CronJobPro heartbeat period to match. The grace period should be a small fraction of the interval, typically 2 to 5 minutes, so you are alerted quickly when a run is missed.

How do I handle API rate limits at the destination?

If the destination API rate-limits bulk pushes, split the transformed array into pages inside the script using jq slice syntax and add a small sleep between requests. Alternatively, push records one at a time in a for loop. The overall script still pings the heartbeat only after all pages succeed.

What if I need to track which records were already synced to avoid duplicates?

The simplest approach is to store a cursor or timestamp in a local file after each successful run, then pass it as a query parameter to the source API on the next run (for example ?updated_since=). Read the cursor file at the top of the script and write it at the bottom, before the heartbeat ping, so a failed run does not advance the cursor.

More recipes

Sync Data Between Two APIs on a Schedule | CronJobPro