Responses into a CSV and upload to SFTP
Fetches all paginated responses from the Informed K12 Campaign Responses API, flattens static + dynamic fields into a CSV, and uploads it to an SFTP server
# ==============================================================================
# Informed K12 Campaign Responses → CSV → SFTP Uploader (PowerShell)
# ------------------------------------------------------------------------------
# Fetches all paginated responses from the Informed K12 Campaign Responses API,
# flattens static + dynamic fields into a CSV, and uploads it to an SFTP server.
#
# Requirements:
# - PowerShell 5.1+ or PowerShell 7+
# - WinSCP .NET assembly (free): https://winscp.net/eng/download.php
# After installing WinSCP, the DLL is typically at:
# C:\Program Files (x86)\WinSCP\WinSCPnet.dll
#
# Usage:
# 1. Fill in the CONFIG section below.
# 2. Open PowerShell and run: .\api_to_sftp.ps1
# ==============================================================================
# ==============================================================================
# CONFIG — fill in your details here
# ==============================================================================
# --- Informed K12 API ---
$CampaignId = "CAMPAIGN_ID" # Change to your Campaign ID
$ApiToken = "YOUR_API_KEY" # Your Informed K12 API token
$ApiUrl = "https://app.informedk12.com/api/v1/campaigns/$CampaignId/responses"
$PageSize = 50 # Informed K12 returns max 50 per page
# Columns that should be stored as numbers (not text) in the CSV
$NumericColumns = @(
"Hourly Rate",
"Hours Not Exceed",
"Stipend",
"Total - Hours",
"Total - Stipend"
)
# --- SFTP ---
$SftpHost = "sftp.example.com"
$SftpPort = 22
$SftpUsername = "your_username"
$SftpPassword = "your_password" # Leave blank "" if using a key file
$SftpKeyFile = "" # Full path to .ppk key file, e.g. "C:\Keys\id_rsa.ppk"
# Leave blank "" if using a password
$SftpRemoteDir = "/uploads/" # Remote folder to upload the CSV into
# --- WinSCP DLL ---
$WinScpDll = "C:\Program Files (x86)\WinSCP\WinSCPnet.dll"
# --- Output ---
$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$CsvFilename = "ik12_responses_$Timestamp.csv"
$LocalTemp = "$env:TEMP\$CsvFilename"
# ==============================================================================
# STEP 1 — Fetch all pages from the Informed K12 API
# ==============================================================================
function Fetch-AllResponses {
Write-Host "[1/3] Fetching campaign responses from Informed K12 (Campaign $CampaignId) ..." -ForegroundColor Cyan
$headers = @{
"accept" = "application/json"
"authorization" = "Bearer $ApiToken"
}
$allData = [System.Collections.Generic.List[object]]::new()
$page = 1
do {
$pagedUrl = "$ApiUrl`?page=$page"
Write-Host " Fetching page $page ..." -ForegroundColor Gray
try {
$response = Invoke-RestMethod -Uri $pagedUrl -Headers $headers -Method Get -ErrorAction Stop
} catch {
Write-Error "API request failed on page $page`: $_"
exit 1
}
$pageData = $response.data
if (-not $pageData) { $pageData = @() }
foreach ($item in $pageData) { $allData.Add($item) }
# Informed K12 returns fewer than $PageSize records on the last page
if ($pageData.Count -lt $PageSize) {
break
}
$page++
} while ($true)
Write-Host " Total records fetched: $($allData.Count)" -ForegroundColor Green
return $allData
}
# ==============================================================================
# STEP 2 — Flatten records and convert to CSV
# ==============================================================================
function Convert-ToCsv {
param([System.Collections.Generic.List[object]]$Records)
Write-Host "[2/3] Converting to CSV ..." -ForegroundColor Cyan
if ($Records.Count -eq 0) {
Write-Error "No records to convert — the API returned no data."
exit 1
}
# Static columns (always present on every response)
$StaticHeaders = @(
"formId",
"currentResponseLink",
"firstSubmittedAt",
"lastSubmittedAt",
"completedAt",
"status",
"currentRecipientName",
"currentRecipientEmail",
"initiatorName",
"initiatorEmail",
"waitingOnStep",
"waitingOnStepLabel"
)
# Collect all unique dynamic field labels across every record
$fieldLabelSet = [System.Collections.Generic.HashSet[string]]::new()
foreach ($item in $Records) {
if ($item.fields) {
foreach ($field in $item.fields) {
[void]$fieldLabelSet.Add($field.label)
}
}
}
$DynamicHeaders = @($fieldLabelSet)
$AllHeaders = $StaticHeaders + $DynamicHeaders
# Build rows
$rows = [System.Collections.Generic.List[string[]]]::new()
foreach ($item in $Records) {
$row = [string[]]::new($AllHeaders.Count)
# Static fields
for ($i = 0; $i -lt $StaticHeaders.Count; $i++) {
$val = $item.($StaticHeaders[$i])
$row[$i] = if ($null -ne $val) { $val.ToString() } else { "" }
}
# Dynamic fields — build a lookup from this record's fields array
$fieldMap = @{}
if ($item.fields) {
foreach ($field in $item.fields) {
$fieldMap[$field.label] = if ($field.value) { $field.value } else { "" }
}
}
for ($i = 0; $i -lt $DynamicHeaders.Count; $i++) {
$label = $DynamicHeaders[$i]
$val = if ($fieldMap.ContainsKey($label)) { $fieldMap[$label] } else { "" }
# Numeric columns: strip commas/spaces and store as a plain number
if ($NumericColumns -contains $label) {
$cleaned = $val.ToString().Replace(",", "").Trim()
$num = 0.0
if ([double]::TryParse($cleaned, [ref]$num)) {
$val = $num.ToString()
} else {
$val = "0"
}
}
$row[$StaticHeaders.Count + $i] = $val.ToString()
}
$rows.Add($row)
}
# Write to temp CSV file
$csv = [System.Text.StringBuilder]::new()
# Header row
$escapedHeaders = $AllHeaders | ForEach-Object { '"' + $_.Replace('"', '""') + '"' }
[void]$csv.AppendLine($escapedHeaders -join ",")
# Data rows
foreach ($row in $rows) {
$escapedCells = $row | ForEach-Object { '"' + $_.Replace('"', '""') + '"' }
[void]$csv.AppendLine($escapedCells -join ",")
}
[System.IO.File]::WriteAllText($LocalTemp, $csv.ToString(), [System.Text.Encoding]::UTF8)
$sizeKb = [math]::Round((Get-Item $LocalTemp).Length / 1KB, 1)
Write-Host " CSV saved: $LocalTemp ($sizeKb KB, $($rows.Count) rows, $($AllHeaders.Count) columns)" -ForegroundColor Green
}
# ==============================================================================
# STEP 3 — Upload CSV to SFTP via WinSCP
# ==============================================================================
function Upload-ToSftp {
Write-Host "[3/3] Uploading to SFTP $SftpHost ..." -ForegroundColor Cyan
if (-not (Test-Path $WinScpDll)) {
Write-Error "WinSCP DLL not found at: $WinScpDll`nDownload WinSCP from https://winscp.net/eng/download.php"
exit 1
}
Add-Type -Path $WinScpDll
$sessionOptions = New-Object WinSCP.SessionOptions -Property @{
Protocol = [WinSCP.Protocol]::Sftp
HostName = $SftpHost
PortNumber = $SftpPort
UserName = $SftpUsername
}
if ($SftpKeyFile -ne "") {
$sessionOptions.SshPrivateKeyPath = $SftpKeyFile
} else {
$sessionOptions.Password = $SftpPassword
}
# Accepts any host key — safe for initial setup.
# For production, replace with your server's fingerprint:
# $sessionOptions.SshHostKeyFingerprint = "ssh-rsa 2048 xx:xx:xx:..."
$sessionOptions.GiveUpSecurityAndAcceptAnySshHostKey = $true
$session = New-Object WinSCP.Session
try {
$session.Open($sessionOptions)
$remotePath = $SftpRemoteDir.TrimEnd("/") + "/" + $CsvFilename
$transferResult = $session.PutFiles($LocalTemp, $remotePath)
$transferResult.Check()
Write-Host " Uploaded to: $remotePath" -ForegroundColor Green
} catch {
Write-Error "SFTP upload failed: $_"
exit 1
} finally {
$session.Dispose()
}
}
# ==============================================================================
# MAIN
# ==============================================================================
$responses = Fetch-AllResponses
Convert-ToCsv -Records $responses
Upload-ToSftp
Remove-Item $LocalTemp -Force -ErrorAction SilentlyContinue
Write-Host ""
Write-Host "Done! '$CsvFilename' uploaded successfully." -ForegroundColor Green
Responses to Google Calendar
function importInformedK12ToCalendar() {
const API_URL = 'https://app.informedk12.com/api/v1/campaigns/“INSERT_CAMPAIGN_ID”/responses?statusGroup=completed';
const API_TOKEN = “YOUR_API_KEY”;
const CALENDAR_ID = “YOUR_CALENDAR_ID”;
const options = {
'method': 'get',
'headers': { 'Authorization': 'Bearer ' + API_TOKEN }
};
const response = UrlFetchApp.fetch(API_URL, options);
const json = JSON.parse(response.getContentText());
// Access the specific calendar instead of the default one
const calendar = CalendarApp.getCalendarById(CALENDAR_ID);
// 2. Iterate through each response in the data array
json.data.forEach(entry => {
const fields = entry.fields;
// Helper function to grab values by their field number
const getVal = (num) => fields.find(f => f.number === num)?.value;
const eventStartDate = getVal(11); // "02/14/2026"
const eventEndDate = getVal(12); // "02/14/2026"
const startTimeStr = getVal(13); // "9:00 AM"
const endTimeStr = getVal(14); // "11:00 AM"
const eventTitle = getVal(19); // "Reason for Use" (e.g., Ninja training)
const location = getVal(10); // "Gym"
const uniqueLink = entry.currentResponseLink;
if (eventStartDate && startTimeStr && endTimeStr) {
const start = new Date(`${eventStartDate} ${startTimeStr}`);
const end = new Date(`${eventEndDate} ${endTimeStr}`);
// --- DEDUPLICATION CHECK ---
// Search for events on this specific day that contain the unique link
const existingEvents = calendar.getEvents(start, end, {search: uniqueLink});
if (existingEvents.length > 0) {
console.log(`Skipping: Event for ${uniqueLink} already exists.`);
return; // Skip to next entry
}
// ----------------------------
calendar.createEvent(eventTitle, start, end, {
location: getVal(10), // Gym
description: `Requested by: ${entry.initiatorName}\nForm Link: ${uniqueLink}`
});
console.log(`Created new event: ${eventTitle}`);
}
});
}
