Adding Fund, Campaign or Appeal to gift/constituent records
I am currently building an API from NXT to our web portal for mission trips. I have been able to get information for both gifts and constituents to the web portal. But when I filter for funds or campaigns or appeals I see the gifts and constituents still but I am not able to match any of them with a fund, appeal or campaign. I tried all three an nothing popped.
Comments
-
@Stew Sheckler
i can't understand what is your question, a little more info and clarity will help me understand to help you0 -
@Alex Wong
I'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.
<?phpnamespace 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) {
throw new \\Exception('Daily API quota exceeded. Please try again later.');
}
// 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.', []);
} else {
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->tokenFetchedAt = time();
$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', [
'base_uri' => $this->baseUrl,
'headers' => [
'Authorization' => 'Bearer ' . $this->accessToken,
'Bb-Api-Subscription-Key' => $_ENV['NXT_SUBSCRIPTION_KEY'],
'Content-Type' => 'application/json',
]
]);
}
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()
{
$tokenService = new NXTTokenService();
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']);
$this->initClient();
return true;
}
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()]);
return false;
}
}
public function fetchMissionTripDonations()
{
LogHelper::log('INFO', '[NXTService] Starting fetchMissionTripDonations', []);
if ($this->isTokenExpired()) {
$this->refreshAccessToken();
}
try {
$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;
} catch (\\Exception $e) {
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;
}
try {
// Get all funds and filter for "Mission Trip"
$fundsResponse = $this->client->get('/fundraising/v1/funds');
$funds = json_decode($fundsResponse->getBody(), true)['value'] ?? [];
$missionTripFunds = array_filter($funds, function ($fund) {
return stripos($fund['description'] ?? '', 'mission trip') !== false;
});
$this->missionTripFundIds = array_column($missionTripFunds, <span style="
0 -
@Alex Wong
I figured out my issues with the grabbing gifts, funds, and constituents. The issue was how the crazy categories identify themselves. The fund categories have completely different fund id's than the ones listed in both the fundraising -→ fund_id and the gift -→ split gift -→ fund_id. That was throwing me way off. Right now I am working on optimizing my script so I don't have to check so many gifts for the information and get a Gateway time out.0
Categories
- All Categories
- 6 Blackbaud Community Help
- 206 bbcon®
- 1.4K Blackbaud Altru®
- 394 Blackbaud Award Management™ and Blackbaud Stewardship Management™
- 1.1K Blackbaud CRM™ and Blackbaud Internet Solutions™
- 15 donorCentrics®
- 357 Blackbaud eTapestry®
- 2.5K Blackbaud Financial Edge NXT®
- 646 Blackbaud Grantmaking™
- 561 Blackbaud Education Management Solutions for Higher Education
- 3.2K Blackbaud Education Management Solutions for K-12 Schools
- 934 Blackbaud Luminate Online® and Blackbaud TeamRaiser®
- 84 JustGiving® from Blackbaud®
- 6.4K Blackbaud Raiser's Edge NXT®
- 3.6K SKY Developer
- 242 ResearchPoint™
- 118 Blackbaud Tuition Management™
- 165 Organizational Best Practices
- 238 The Tap (Just for Fun)
- 33 Blackbaud Community Challenges
- 28 PowerUp Challenges
- 3 (Open) Raiser's Edge NXT PowerUp Challenge: Product Update Briefing
- 3 (Closed) Raiser's Edge NXT PowerUp Challenge: Standard Reports+
- 3 (Closed) Raiser's Edge NXT PowerUp Challenge: Email Marketing
- 3 (Closed) Raiser's Edge NXT PowerUp Challenge: Gift Management
- 4 (Closed) Raiser's Edge NXT PowerUp Challenge: Event Management
- 3 (Closed) Raiser's Edge NXT PowerUp Challenge: Home Page
- 4 (Closed) Raiser's Edge NXT PowerUp Challenge: Standard Reports
- 4 (Closed) Raiser's Edge NXT PowerUp Challenge: Query
- 778 Community News
- 2.9K Jobs Board
- 53 Blackbaud SKY® Reporting Announcements
- 47 Blackbaud CRM Higher Ed Product Advisory Group (HE PAG)
- 19 Blackbaud CRM Product Advisory Group (BBCRM PAG)
