@Ben LambertWhat if my gifts gets do not register campaign ids I am able to pull using a separate get?
@Stew ShecklerNot exactly sure what your use case is. Can you explain what you trying to accomplish?
@Alex WongI'll give you my current code and see if you can tell me why I am getting a 200 OK but I am not pulling any gift/constituent data only getting my Campaign and Fund Data. What I am trying to do is get all the information from NXT about our Mission Trips for funding and donations and push it to a webportal we are using called ServiceReef. They have specific categories that I am struggling to pull from NXT in an orderly fashion so they will sync. I hope that makes sense. <?php
namespace App\\Services;
use GuzzleHttp\\Client;
use App\\Services\\LogHelper;
use App\\Services\\NXTTokenService;
class NXTService
{
private $constituentCache = [];
private $lastRequestTime = 0;
private $requestsInLastSecond = 0;
private $dailyRequestCount = 0;
private $dailyQuotaReset = 0;
private $maxRequestsPerSecond = 10;
private $maxDailyRequests = 25000; // Standard tier limit
private function handleRateLimiting(\\Exception $e)
if ($e->getCode() === 429) { // Rate limit exceeded
$retryAfter = $e->getResponse()->getHeader('Retry-After')[0] ?? 1;
LogHelper::log('INFO', '[NXTService] Rate limit exceeded, waiting...', ['retry_after' => $retryAfter]);
sleep((int)$retryAfter);
return true;
} elseif ($e->getCode() === 403) { // Daily quota exceeded
$retryAfter = $e->getResponse()->getHeader('Retry-After')[0] ?? 3600;
LogHelper::log('ERROR', '[NXTService] Daily quota exceeded', ['retry_after' => $retryAfter]);
throw new \\Exception('Daily API quota exceeded. Please try again later.');
}
return false;
private function checkRateLimits()
$now = time();
// Reset daily counter if needed (24 hour period)
if ($now - $this->dailyQuotaReset >= 86400) {
$this->dailyRequestCount = 0;
$this->dailyQuotaReset = $now;
// Check if we've exceeded daily quota
if ($this->dailyRequestCount >= $this->maxDailyRequests) {
// Handle per-second rate limiting
if ($now === $this->lastRequestTime) {
$this->requestsInLastSecond++;
if ($this->requestsInLastSecond >= $this->maxRequestsPerSecond) {
usleep(100000); // Sleep 100ms if we're at the rate limit
$this->requestsInLastSecond = 0;
$this->lastRequestTime = time();
} else {
$this->requestsInLastSecond = 1;
$this->lastRequestTime = $now;
$this->dailyRequestCount++;
/**
* Cached array of Mission Trip fund IDs
* @var array|null
*/
private $missionTripFundIds = null;
private $client;
private $baseUrl;
private $accessToken;
private $tokenFetchedAt;
private $tokenExpiresIn;
public function __construct()
$this->baseUrl = $_ENV['NXT_BASE_URL'] ?? '';
if (empty($this->baseUrl)) {
LogHelper::log('ERROR', '[NXTService] NXT_BASE_URL is missing or empty!', []);
echo "[ERROR] Required environment variable NXT_BASE_URL is missing or empty!\\n";
var_dump($_ENV['NXT_BASE_URL']);
exit(1);
LogHelper::log('INFO', 'NXTService NXT_BASE_URL', ['NXT_BASE_URL' => $this->baseUrl]);
$this->loadAccessToken();
$this->initClient();
private function loadAccessToken()
$tokenService = new NXTTokenService();
LogHelper::log('INFO', '[NXTService] Loading access token from NXTTokenService', []);
$accessToken = $tokenService->getValidAccessToken();
if (!$accessToken) {
LogHelper::log('ERROR', 'NXTService: Failed to load access token from NXTTokenService.', []);
LogHelper::log('INFO', '[NXTService] Access token loaded successfully', []);
$this->accessToken = $accessToken;
$this->tokenFetchedAt = time();
$this->tokenExpiresIn = 3600;
private function saveAccessToken($token, $expiresIn)
LogHelper::log('INFO', '[NXTService] Saving new access token', ['expires_in' => $expiresIn]);
$this->accessToken = $token;
$this->tokenExpiresIn = $expiresIn;
// Optionally persist to file or cache if needed
// For now, just update the instance
private function initClient()
$this->client = new Client([
'base_uri' => $this->baseUrl,
'headers' => [
'Bb-Api-Subscription-Key' => $_ENV['NXT_SUBSCRIPTION_KEY'],
'Authorization' => 'Bearer ' . $this->accessToken,
'Content-Type' => 'application/json',
],
'timeout' => 20, // Increased to 20 seconds to handle larger batches of data
'connect_timeout' => 5 // Keep at 5 seconds as connection issues manifest quickly
]);
LogHelper::log('INFO', '[NXTService] Guzzle Client initialized', [
]
private function isTokenExpired()
if (!$this->accessToken) return true;
// Consider token expired if within 2 minutes of expiry
return (time() - $this->tokenFetchedAt) > ($this->tokenExpiresIn - 120);
private function refreshAccessToken()
LogHelper::log('INFO', '[NXTService] Attempting to refresh Blackbaud NXT access token', []);
try {
$result = $tokenService->refreshAccessToken();
if ($result && isset($result['access_token'])) {
LogHelper::log('INFO', '[NXTService] Access token refreshed successfully', []);
$this->saveAccessToken($result['access_token'], $result['expires_in']);
LogHelper::log('ERROR', 'NXTService: Failed to refresh Blackbaud NXT access token - no access_token in response.', []);
} catch (\\Exception $e) {
LogHelper::log('ERROR', 'NXTService: Exception during refreshAccessToken', ['error' => $e->getMessage()]);
public function fetchMissionTripDonations()
LogHelper::log('INFO', '[NXTService] Starting fetchMissionTripDonations', []);
if ($this->isTokenExpired()) {
$this->refreshAccessToken();
$startDate = '2025-01-01T00:00:00-04:00';
$endDate = '2025-12-31T23:59:59-04:00';
// Step 1: Get all campaigns and filter for "Mission Trip"
$campaignsResponse = $this->client->get('/fundraising/v1/campaigns');
$campaigns = json_decode($campaignsResponse->getBody(), true)['value'] ?? [];
// Debug: Log first campaign structure
if (!empty($campaigns)) {
LogHelper::log('DEBUG', '[NXTService] First campaign structure: ' . json_encode($campaigns[0], JSON_PRETTY_PRINT));
// Debug: Log all campaign descriptions
$campaignDescs = array_map(function($campaign) {
return $campaign['description'] ?? 'NO_DESCRIPTION';
}, $campaigns);
LogHelper::log('DEBUG', '[NXTService] All campaign descriptions: ' . json_encode($campaignDescs));
$missionTripCampaigns = array_filter($campaigns, function ($campaign) {
return ($campaign['description'] ?? '') === 'Mission Trips' ||
($campaign['description'] ?? '') === '2025 Mission Trips';
});
$campaignIds = array_column($missionTripCampaigns, 'id');
// Step 2: Get all funds and filter for "Mission Trip"
$fundsResponse = $this->client->get('/fundraising/v1/funds');
$funds = json_decode($fundsResponse->getBody(), true)['value'] ?? [];
// Debug: Log first fund structure
if (!empty($funds)) {
LogHelper::log('DEBUG', '[NXTService] First fund structure: ' . json_encode($funds[0], JSON_PRETTY_PRINT));
// Debug: Log all fund descriptions
$fundDescs = array_map(function($fund) {
return $fund['description'] ?? 'NO_DESCRIPTION';
}, $funds);
LogHelper::log('DEBUG', '[NXTService] All fund descriptions: ' . json_encode($fundDescs));
$missionTripFunds = array_filter($funds, function ($fund) {
$desc = $fund['description'] ?? '';
return strpos($desc, 'Mission Trip :') === 0;
$fundIds = array_column($missionTripFunds, 'id');
LogHelper::log('INFO', 'Filtered campaigns: ' . json_encode($campaignIds));
LogHelper::log('INFO', 'Filtered funds: ' . json_encode($fundIds));
// Step 3: Fetch all gifts in 2025
$offset = 0;
$limit = 100;
$gifts = [];
do {
// Build campaign filter
$campaignFilter = count($campaignIds) > 0 ?
'campaign_id in (' . implode(',', $campaignIds) . ')' : 'false';
// Build fund filter
$fundFilter = count($fundIds) > 0 ?
'fund_id in (' . implode(',', $fundIds) . ')' : 'false';
$response = $this->client->get('/gift/v1/gifts', [
'query' => [
'filter' => "(date ge '$startDate' and date le '$endDate') and (($campaignFilter) or ($fundFilter))",
'skip' => $offset,
'limit' => 100,
'include' => 'constituent_id,amount,date,lookup_id,payment_method,payments,gift_splits,gift_status,is_anonymous,fund,campaign'
$batch = json_decode($response->getBody(), true)['value'] ?? [];
foreach ($batch as $gift) {
$gifts[] = $gift;
LogHelper::log('INFO', '[NXTService] Processed gifts page', [
'page_size' => count($batch),
'total_filtered_gifts' => count($gifts),
'page' => ($offset / $limit) + 1
$offset += $limit;
} while (count($batch) === $limit);
LogHelper::log('INFO', 'Filtered mission trip gifts count: ' . count($gifts));
return $gifts;
LogHelper::log('ERROR', '[NXTService] Error in fetchMissionTripDonations', [
'error' => $e->getMessage()
throw $e;
* Fetch and cache the fund IDs that are categorized as "Mission Trip".
* Returns an array of fund IDs.
private function getMissionTripFundIds()
// Use cached list if available
if ($this->missionTripFundIds !== null) {
LogHelper::log('DEBUG', '[NXTService] Using cached mission trip fund IDs', [
'count' => count($this->missionTripFundIds)
return $this->missionTripFundIds;
// Get all funds and filter for "Mission Trip"
return stripos($fund['description'] ?? '', 'mission trip') !== false;
$this->missionTripFundIds = array_column($missionTripFunds, 'id');
LogHelper::log('INFO', '[NXTService] Found mission trip funds', [
'count' => count($this->missionTripFundIds),
'fund_ids' => $this->missionTripFundIds
LogHelper::log('ERROR', '[NXTService] Error fetching mission trip funds', [
return [];
* Determine if a gift is for a Mission Trip fund.
private function isMissionTripGift($gift)
$fundId = $gift['fund']['id'] ?? null;
if ($fundId) {
$missionTripFundIds = $this->getMissionTripFundIds();
$isMissionTrip = in_array($fundId, $missionTripFundIds);
LogHelper::log('DEBUG', '[NXTService] Gift fund ID isMissionTrip', [
'fund_id' => $fundId,
'is_mission_trip' => $isMissionTrip
return $isMissionTrip;
* Transform a Blackbaud NXT gift record to a Service Reef-compatible donation object.
* Includes: Event, Constituent info, Gift type, Gift amount, Gift Date, Fund Name, ID number, and Contact info.
private function getCo
@Stew ShecklerI don't normally help people do code review like this, it takes up a lot of time just to read through a big chunk of code. So this is one time only…
you are “trying” to call the Get Gift List API call with start_gift_date, end_gift_date, campaign_id, and fund_id query parameter, however:
If you want to do complex and/or filtering, consider using Query API instead