Use Query parameters to automatically link payment to fund/campaign and appeal?

Is there a way to use query parameters in the Payment API to automatically link a payment to a fund, campaign and appeal in Raiser's Edge?

Comments

  • Hi Nathan,


    The Payments API is really more focused on processing the actual payment itself - to manage gift information in Raiser's Edge NXT, you'll want to use the Gift SKY API. For example, when you create a gift record, you can specify an array of "gift split" objects which will include the campaign, fund, appeal (and package) details.
  • @Ben Lambert
    What if my gifts gets do not register campaign ids I am able to pull using a separate get?

  • Alex Wong
    Alex Wong Community All-Star
    Ninth Anniversary Kudos 5 Facilitator 3 Raiser's Edge NXT Fall 2025 Product Update Briefing Badge

    @Stew Sheckler
    Not exactly sure what your use case is. Can you explain what you trying to accomplish?

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

    <?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) {

    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, 'id');


    LogHelper::log('INFO', '[NXTService] Found mission trip funds', [

    'count' => count($this->missionTripFundIds),

    'fund_ids' => $this->missionTripFundIds

    ]);


    return $this->missionTripFundIds;


    } catch (\\Exception $e) {

    LogHelper::log('ERROR', '[NXTService] Error fetching mission trip funds', [

    'error' => $e->getMessage()

    ]);

    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;

    }

    return false;

    }


    /**

    * 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

  • Alex Wong
    Alex Wong Community All-Star
    Ninth Anniversary Kudos 5 Facilitator 3 Raiser's Edge NXT Fall 2025 Product Update Briefing Badge

    @Stew Sheckler
    I 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:

    1. your code is using ODATA Query: (date ge '$startDate' and date le '$endDate') and (($campaignFilter) or ($fundFilter))
      • this is not how you filter with SKY API, you will want to put in debug code to see what is the request object looks like, it should have the query paramter looking like this: start_gift_date=2025-01-01&end_gift_date=2025-12-31&campaign_id=1&campaign_id=2&campaign_id=10
    2. Get Gift List API does not do OR condition, query parameter is all AND condition, that's why in my sample above, I did not include the fund_id parameter, as it will only add another AND condition to your pull

    If you want to do complex and/or filtering, consider using Query API instead