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
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
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
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
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
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.