signalapp/Signal-Server · error · InvalidLevelException
unrecognized level
Error message
unrecognized level
What it means
OneTimeDonationUtil.getLevelDetails maps a Stripe payment-intent metadata 'level' to a donation expiration duration. If the level from the payment intent is not ONE_TIME_DONATION or ONE_TIME_GIFT_DONATION, it logs the unknown level and throws InvalidLevelException('unrecognized level').
Solutions
- Verify the Stripe payment intent metadata 'level' matches a DonationLevel enum value known to the server
- Redeploy/update the server to a build that includes the new donation level
- Correct the checkout/donation configuration so it only produces supported levels
- Manually fix the level metadata on the affected payment intent and retry processing
Example fix
// before (checkout metadata) "level": "boost_champion" // after "level": "ONE_TIME_DONATION"
Defensive patterns
Strategy: try-catch
Validate before calling
try {
DonationLevel.valueOf(levelString);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("unsupported donation level: " + levelString);
} Type guard
static boolean isKnownLevel(String level) {
for (DonationLevel l : DonationLevel.values()) {
if (l.name().equals(level)) return true;
}
return false;
} Try / catch
try {
processPayment(paymentIntent);
} catch (InvalidLevelException e) {
LOGGER.warn("Skipping payment intent {} with unknown level", paymentIntent.id());
} Prevention
- Keep the checkout page's level metadata in sync with DonationLevel enum values
- Add integration tests covering every donation level the checkout can emit
- Monitor the 'level unknown' server log for drift between checkout and server
When it happens
Trigger: Processing a completed Stripe payment intent whose 'level' metadata value is missing, misspelled, or was set by an older/newer checkout flow that defines donation levels the server build doesn't recognize.
Common situations: Server deploy lagging behind a checkout page that added a new donation level; Stripe dashboard manual test payments lacking metadata; stale configuration of one-time donation levels after a rename.
Understand the failure class
Background: Invalid enum value errors: "Unknown type", "Invalid scope", "must be one of" — when a string is not on the library's allowed list — this error's family across 23 libraries.
Related errors
- 400 Bad Request (UNSUPPORTED_LEVEL)
- cannot use app store purchases for one-time donations
- cannot use play billing for one-time donations
- enum field must be specified
- Invalid action:
AI-assisted analysis of signalapp/Signal-Server@100ab61c82 (2026-09-09).
Data as JSON: /api/errors/f457306e970e82fe.
Report an issue: GitHub.
Appendix: source
Thrown at service/src/main/java/org/whispersystems/textsecuregcm/grpc/OneTimeDonationUtil.java:112
// item name will not match. We could try to align with the locales PayPal documents <https://developer.paypal.com/reference/locale-codes/#supported-locale-codes>
// but that's a moving target, and we can hopefully have one of them be better for the user by selecting
// independently.
final Locale locale = SubscriptionsUtil.getPayPalLocale(acceptableLocales);
final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLocales,
org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY);
return new LocalizedPayPalDonationLineItem(locale, localizedLineItemName);
}
public static DonationLevelDetails getLevelDetails(final PaymentDetails paymentDetails,
final OneTimeDonationConfiguration oneTimeDonationConfiguration)
throws InvalidLevelException {
final Duration levelExpiration = switch (paymentDetails.level()) {
case ONE_TIME_DONATION -> oneTimeDonationConfiguration.boost().expiration();
case ONE_TIME_GIFT_DONATION -> oneTimeDonationConfiguration.gift().expiration();
default -> {
LOGGER.error("level ({}) returned from payment intent that is unknown to the server", paymentDetails.level());
throw new InvalidLevelException("unrecognized level");
}
};
return new DonationLevelDetails(paymentDetails.level(), levelExpiration);
}
}
View on GitHub (pinned to 100ab61c82)