diff --git a/app/assets/javascripts/admin/controllers/admin_badge_controller.js b/app/assets/javascripts/admin/controllers/admin_badge_controller.js
new file mode 100644
index 000000000..d45f280d1
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin_badge_controller.js
@@ -0,0 +1,19 @@
+/**
+  This is the itemController for `Discourse.AdminBadgesController`. Its main purpose
+  is to indicate which badge was selected.
+
+  @class AdminBadgeController
+  @extends Discourse.ObjectController
+  @namespace Discourse
+  @module Discourse
+**/
+
+Discourse.AdminBadgeController = Discourse.ObjectController.extend({
+  /**
+    Whether this badge has been selected.
+
+    @property selected
+    @type {Boolean}
+  **/
+  selected: Discourse.computed.propertyEqual('model.name', 'parentController.selectedItem.name')
+});
diff --git a/app/assets/javascripts/admin/controllers/admin_badges_controller.js b/app/assets/javascripts/admin/controllers/admin_badges_controller.js
new file mode 100644
index 000000000..705aad553
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin_badges_controller.js
@@ -0,0 +1,91 @@
+/**
+  This controller supports the interface for dealing with badges.
+
+  @class AdminBadgesController
+  @extends Ember.ArrayController
+  @namespace Discourse
+  @module Discourse
+**/
+Discourse.AdminBadgesController = Ember.ArrayController.extend({
+  itemController: 'adminBadge',
+
+  /**
+    Show the displayName only if it is different from the name.
+
+    @property showDisplayName
+    @type {Boolean}
+  **/
+  showDisplayName: Discourse.computed.propertyNotEqual('selectedItem.name', 'selectedItem.displayName'),
+
+  /**
+    We don't allow setting a description if a translation for the given badge name
+    exists.
+
+    @property canEditDescription
+    @type {Boolean}
+  **/
+  canEditDescription: Em.computed.none('selectedItem.translatedDescription'),
+
+  actions: {
+
+    /**
+      Create a new badge and select it.
+
+      @method newBadge
+    **/
+    newBadge: function() {
+      var badge = Discourse.Badge.create({
+        name: I18n.t('admin.badges.new_badge')
+      });
+      this.pushObject(badge);
+      this.send('selectBadge', badge);
+    },
+
+    /**
+      Select a particular badge.
+
+      @method selectBadge
+      @param {Discourse.Badge} badge The badge to be selected
+    **/
+    selectBadge: function(badge) {
+      this.set('selectedItem', badge);
+    },
+
+    /**
+      Save the selected badge.
+
+      @method save
+    **/
+    save: function() {
+      var badge = this.get('selectedItem');
+      badge.set('disableSave', true);
+      badge.save().then(function() {
+        badge.set('disableSave', false);
+      });
+    },
+
+    /**
+      Confirm before destroying the selected badge.
+
+      @method destroy
+    **/
+    destroy: function() {
+      var self = this;
+      return bootbox.confirm(I18n.t("admin.badges.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
+        if (result) {
+          var selected = self.get('selectedItem');
+          selected.destroy().then(function() {
+            // Success.
+            self.set('selectedItem', null);
+            self.get('model').removeObject(selected);
+          }, function() {
+            // Failure.
+            bootbox.alert(I18n.t('generic_error'));
+          });
+        }
+      });
+    }
+
+  }
+
+});
diff --git a/app/assets/javascripts/admin/controllers/admin_user_controller.js b/app/assets/javascripts/admin/controllers/admin_user_controller.js
index c0674fd6d..ec1465027 100644
--- a/app/assets/javascripts/admin/controllers/admin_user_controller.js
+++ b/app/assets/javascripts/admin/controllers/admin_user_controller.js
@@ -24,6 +24,10 @@ Discourse.AdminUserIndexController = Discourse.ObjectController.extend({
     return Discourse.SiteSettings.must_approve_users;
   }.property(),
 
+  showBadges: function() {
+    return Discourse.SiteSettings.enable_badges;
+  }.property(),
+
   primaryGroupDirty: Discourse.computed.propertyNotEqual('originalPrimaryGroupId', 'primary_group_id'),
 
   actions: {
diff --git a/app/assets/javascripts/admin/routes/admin_badges_route.js b/app/assets/javascripts/admin/routes/admin_badges_route.js
new file mode 100644
index 000000000..1c606b557
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin_badges_route.js
@@ -0,0 +1,14 @@
+Discourse.AdminBadgesRoute = Discourse.Route.extend({
+
+  model: function() {
+    return Discourse.Badge.findAll();
+  },
+
+  setupController: function(controller, model) {
+    Discourse.ajax('/admin/badges/types').then(function(json) {
+      controller.set('badgeTypes', json.badge_types);
+    });
+    controller.set('model', model);
+  }
+
+});
diff --git a/app/assets/javascripts/admin/routes/admin_routes.js b/app/assets/javascripts/admin/routes/admin_routes.js
index 3b847a36d..21b2b38b6 100644
--- a/app/assets/javascripts/admin/routes/admin_routes.js
+++ b/app/assets/javascripts/admin/routes/admin_routes.js
@@ -57,5 +57,7 @@ Discourse.Route.buildRoutes(function() {
       });
     });
 
+    this.route('badges');
+
   });
 });
diff --git a/app/assets/javascripts/admin/templates/admin.js.handlebars b/app/assets/javascripts/admin/templates/admin.js.handlebars
index f8044020f..43f18b5e2 100644
--- a/app/assets/javascripts/admin/templates/admin.js.handlebars
+++ b/app/assets/javascripts/admin/templates/admin.js.handlebars
@@ -24,6 +24,7 @@
           <li>{{#link-to 'admin.customize'}}{{i18n admin.customize.title}}{{/link-to}}</li>
           <li>{{#link-to 'admin.api'}}{{i18n admin.api.title}}{{/link-to}}</li>
           <li>{{#link-to 'admin.backups'}}{{i18n admin.backups.title}}{{/link-to}}</li>
+          <li>{{#link-to 'admin.badges'}}{{i18n admin.badges.title}}{{/link-to}}</li>
         {{/if}}
       </ul>
 
diff --git a/app/assets/javascripts/admin/templates/badges.js.handlebars b/app/assets/javascripts/admin/templates/badges.js.handlebars
new file mode 100644
index 000000000..ff39e7418
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/badges.js.handlebars
@@ -0,0 +1,62 @@
+<div class="badges">
+
+  <div class='content-list span6'>
+    <h3>{{i18n admin.badges.title}}</h3>
+    <ul>
+      {{#each}}
+      <li>
+        <a {{action selectBadge this}} {{bind-attr class="selected:active"}}>
+          {{displayName}}
+          {{#if newBadge}}
+          (*)
+          {{/if}}
+        </a>
+      </li>
+      {{/each}}
+    </ul>
+    <button {{action newBadge}} class='btn'>{{i18n admin.badges.new}}</button>
+  </div>
+
+  {{#if selectedItem}}
+  {{#with selectedItem}}
+  <div class='current-badge span12'>
+    <form class="form-horizontal">
+      <div>
+        <label for="name">{{i18n admin.badges.name}}</label>
+        {{input type="text" name="name" value=name}}
+      </div>
+
+      {{#if controller.showDisplayName}}
+      <div>
+        <strong>{{i18n admin.badges.display_name}}</strong>
+        {{displayName}}
+      </div>
+      {{/if}}
+
+      <div>
+        <label for="badge_type_id">{{i18n admin.badges.badge_type}}</label>
+        {{view Ember.Select name="badge_type_id" value=badge_type_id
+                            content=controller.badgeTypes
+                            optionValuePath="content.id"
+                            optionLabelPath="content.name"}}
+      </div>
+
+      <div>
+        <label for="description">{{i18n admin.badges.description}}</label>
+        {{#if controller.canEditDescription}}
+          {{textarea name="description" value=description}}
+        {{else}}
+          {{textarea name="description" value=translatedDescription disabled=true}}
+        {{/if}}
+      </div>
+
+      <div class='buttons'>
+        <button {{action save}} {{bind-attr disabled=disableSave}} class='btn btn-primary'>{{i18n admin.badges.save}}</button>
+        <a {{action destroy}} class='delete-link'>{{i18n admin.badges.delete}}</a>
+      </div>
+    </form>
+  </div>
+  {{/with}}
+  {{/if}}
+
+</div>
diff --git a/app/assets/javascripts/admin/templates/user_index.js.handlebars b/app/assets/javascripts/admin/templates/user_index.js.handlebars
index a99918ade..8af5cd2c5 100644
--- a/app/assets/javascripts/admin/templates/user_index.js.handlebars
+++ b/app/assets/javascripts/admin/templates/user_index.js.handlebars
@@ -336,6 +336,12 @@
   </div>
 </section>
 
+{{#if showBadges}}
+<section class='details'>
+  <h1>{{i18n admin.badges.title}}</h1>
+</section>
+{{/if}}
+
 <section>
   <hr/>
   <button {{bind-attr class=":btn :btn-danger :pull-right deleteForbidden:hidden"}} {{action destroy target="content"}} {{bind-attr disabled="deleteForbidden"}}>
diff --git a/app/assets/javascripts/discourse/models/badge.js b/app/assets/javascripts/discourse/models/badge.js
new file mode 100644
index 000000000..ee712f601
--- /dev/null
+++ b/app/assets/javascripts/discourse/models/badge.js
@@ -0,0 +1,164 @@
+/**
+  A data model representing a badge on Discourse
+
+  @class Badge
+  @extends Discourse.Model
+  @namespace Discourse
+  @module Discourse
+**/
+Discourse.Badge = Discourse.Model.extend({
+  /**
+    Is this a new badge?
+
+    @property newBadge
+    @type {String}
+  **/
+  newBadge: Em.computed.none('id'),
+
+  /**
+    @private
+
+    The name key to use for fetching i18n translations.
+
+    @property i18nNameKey
+    @type {String}
+  **/
+  i18nNameKey: function() {
+    return this.get('name').toLowerCase().replace(/\s/g, '_');
+  }.property('name'),
+
+  /**
+    The display name of this badge. Attempts to use a translation and falls back to
+    the actual name.
+
+    @property displayName
+    @type {String}
+  **/
+  displayName: function() {
+    var i18nKey = "badges." + this.get('i18nNameKey') + ".name";
+    return I18n.t(i18nKey, {defaultValue: this.get('name')});
+  }.property('name', 'i18nNameKey'),
+
+  /**
+    The i18n translated description for this badge. `null` if no translation exists.
+
+    @property translatedDescription
+    @type {String}
+  **/
+  translatedDescription: function() {
+    var i18nKey = "badges." + this.get('i18nNameKey') + ".description",
+        translation = I18n.t(i18nKey);
+    if (translation.match(new RegExp(i18nKey))) {
+      translation = null;
+    }
+    return translation;
+  }.property('i18nNameKey'),
+
+  /**
+    Update this badge with the response returned by the server on save.
+
+    @method updateFromJson
+    @param {Object} json The JSON response returned by the server
+  **/
+  updateFromJson: function(json) {
+    var self = this;
+    Object.keys(json.badge).forEach(function(key) {
+      self.set(key, json.badge[key]);
+    });
+    json.badge_types.forEach(function(badgeType) {
+      if (badgeType.id === self.get('badge_type_id')) {
+        self.set('badge_type', Object.create(badgeType));
+      }
+    });
+  },
+
+  /**
+    Save and update the badge from the server's response.
+
+    @method save
+    @returns {Promise} A promise that resolves to the updated `Discourse.Badge`
+  **/
+  save: function() {
+    var url = "/admin/badges",
+        requestType = "POST",
+        self = this;
+    if (!this.get('newBadge')) {
+      // We are updating an existing badge.
+      url += "/" + this.get('id');
+      requestType = "PUT";
+    }
+    return Discourse.ajax(url, {
+      type: requestType,
+      data: {
+        name: this.get('name'),
+        description: this.get('description'),
+        badge_type_id: this.get('badge_type_id')
+      }
+    }).then(function(json) {
+      self.updateFromJson(json);
+      return self;
+    });
+  },
+
+  /**
+    Destroy the badge.
+
+    @method destroy
+    @returns {Promise} A promise that resolves to the server response
+  **/
+  destroy: function() {
+    if (this.get('newBadge')) return Ember.RSVP.resolve();
+    return Discourse.ajax("/admin/badges/" + this.get('id'), {
+      type: "DELETE"
+    });
+  }
+});
+
+Discourse.Badge.reopenClass({
+  /**
+    Create `Discourse.Badge` instances from the server JSON response.
+
+    @method createFromJson
+    @param {Object} json The JSON returned by the server
+    @returns Array or instance of `Discourse.Badge` depending on the input JSON
+  **/
+  createFromJson: function(json) {
+    // Create BadgeType objects.
+    var badgeTypes = {};
+    if ('badge_types' in json) {
+      json.badge_types.forEach(function(badgeTypeJson) {
+        badgeTypes[badgeTypeJson.id] = Ember.Object.create(badgeTypeJson);
+      });
+    }
+
+    // Create Badge objects.
+    var badges = [];
+    if ("badge" in json) {
+      badges = [json.badge];
+    } else {
+      badges = json.badges;
+    }
+    badges = badges.map(function(badgeJson) {
+      var badge = Discourse.Badge.create(badgeJson);
+      badge.set('badge_type', badgeTypes[badge.get('badge_type_id')]);
+      return badge;
+    });
+    if ("badge" in json) {
+      return badges[0];
+    } else {
+      return badges;
+    }
+  },
+
+  /**
+    Find all `Discourse.Badge` instances that have been defined.
+
+    @method findAll
+    @returns {Promise} a promise that resolves to an array of `Discourse.Badge`
+  **/
+  findAll: function() {
+    return Discourse.ajax('/admin/badges').then(function(badgesJson) {
+      return Discourse.Badge.createFromJson(badgesJson);
+    });
+  }
+});
diff --git a/app/assets/javascripts/discourse/models/user_badge.js b/app/assets/javascripts/discourse/models/user_badge.js
new file mode 100644
index 000000000..f0f6595c8
--- /dev/null
+++ b/app/assets/javascripts/discourse/models/user_badge.js
@@ -0,0 +1,57 @@
+/**
+  A data model representing a user badge grant on Discourse
+
+  @class UserBadge
+  @extends Discourse.Model
+  @namespace Discourse
+  @module Discourse
+**/
+Discourse.UserBadge = Discourse.Model.extend({
+});
+
+Discourse.UserBadge.reopenClass({
+  /**
+    Create `Discourse.UserBadge` instances from the server JSON response.
+
+    @method createFromJson
+    @param {Object} json The JSON returned by the server
+    @returns Array or instance of `Discourse.UserBadge` depending on the input JSON
+  **/
+  createFromJson: function(json) {
+    // Create User objects.
+    var users = {};
+    json.users.forEach(function(userJson) {
+      users[userJson.id] = Discourse.User.create(userJson);
+    });
+
+    // Create the badges.
+    var badges = {};
+
+    Discourse.Badge.createFromJson(json).forEach(function(badge) {
+      badges[badge.get('id')] = badge;
+    });
+
+    // Create UserBadge object(s).
+    var userBadges = [];
+    if ("user_badge" in json) {
+      userBadges = [json.user_badge];
+    } else {
+      userBadges = json.user_badges;
+    }
+
+    userBadges = userBadges.map(function(userBadgeJson) {
+      var userBadge = Discourse.UserBadge.create(userBadgeJson);
+      userBadge.set('badge', badges[userBadge.get('badge_id')]);
+      if (userBadge.get('granted_by_id')) {
+        userBadge.set('granted_by', users[userBadge.get('granted_by_id')]);
+      }
+      return userBadge;
+    });
+
+    if ("user_badge" in json) {
+      return userBadges[0];
+    } else {
+      return userBadges;
+    }
+  }
+});
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index 8b608e4d7..7a69efcd8 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -285,6 +285,30 @@ section.details {
   }
 }
 
+// Badges area
+.badges {
+  .content-list ul {
+    margin-bottom: 10px;
+  }
+
+  .current-badge {
+    margin: 20px;
+  }
+
+  .form-horizontal {
+    label {
+      font-weight: bold;
+    }
+    & > div {
+      margin-top: 10px;
+    }
+    .delete-link {
+      margin-left: 15px;
+      margin-top: 5px;
+    }
+  }
+}
+
 // Customise area
 .customize {
   .nav.nav-pills {
diff --git a/app/controllers/admin/badges_controller.rb b/app/controllers/admin/badges_controller.rb
new file mode 100644
index 000000000..1cfb1ef8e
--- /dev/null
+++ b/app/controllers/admin/badges_controller.rb
@@ -0,0 +1,44 @@
+class Admin::BadgesController < Admin::AdminController
+  def index
+    badges = Badge.all.to_a
+    render_serialized(badges, BadgeSerializer, root: "badges")
+  end
+
+  def badge_types
+    badge_types = BadgeType.all.to_a
+    render_serialized(badge_types, BadgeTypeSerializer, root: "badge_types")
+  end
+
+  def create
+    badge = Badge.new
+    update_badge_from_params(badge)
+    badge.save!
+    render_serialized(badge, BadgeSerializer, root: "badge")
+  end
+
+  def update
+    badge = find_badge
+    update_badge_from_params(badge)
+    badge.save!
+    render_serialized(badge, BadgeSerializer, root: "badge")
+  end
+
+  def destroy
+    find_badge.destroy
+    render nothing: true
+  end
+
+  private
+    def find_badge
+      params.require(:id)
+      Badge.find(params[:id])
+    end
+
+    def update_badge_from_params(badge)
+      params.permit(:name, :description, :badge_type_id)
+      badge.name = params[:name]
+      badge.description = params[:description]
+      badge.badge_type = BadgeType.find(params[:badge_type_id])
+      badge
+    end
+end
diff --git a/app/controllers/user_badges_controller.rb b/app/controllers/user_badges_controller.rb
new file mode 100644
index 000000000..3044d7c16
--- /dev/null
+++ b/app/controllers/user_badges_controller.rb
@@ -0,0 +1,58 @@
+class UserBadgesController < ApplicationController
+  def index
+    params.require(:username)
+    user = fetch_user_from_params
+    render json: user.user_badges
+  end
+
+  def create
+    params.require(:username)
+    user = fetch_user_from_params
+
+    unless can_assign_badge_to_user?(user)
+      render json: failed_json, status: 403
+      return
+    end
+
+    badge = fetch_badge_from_params
+    user_badge = BadgeGranter.grant(badge, user, granted_by: current_user)
+
+    render json: user_badge
+  end
+
+  def destroy
+    params.require(:id)
+    user_badge = UserBadge.find(params[:id])
+
+    unless can_assign_badge_to_user?(user_badge.user)
+      render json: failed_json, status: 403
+      return
+    end
+
+    BadgeGranter.revoke(user_badge)
+    render json: success_json
+  end
+
+  private
+
+    # Get the badge from either the badge name or id specified in the params.
+    def fetch_badge_from_params
+      badge = nil
+
+      params.permit(:badge_name)
+      if params[:badge_name].nil?
+        params.require(:badge_id)
+        badge = Badge.where(id: params[:badge_id]).first
+      else
+        badge = Badge.where(name: params[:badge_name]).first
+      end
+      raise Discourse::NotFound.new if badge.blank?
+
+      badge
+    end
+
+    def can_assign_badge_to_user?(user)
+      master_api_call = current_user.nil? && is_api?
+      master_api_call or guardian.can_grant_badges?(user)
+    end
+end
diff --git a/app/models/badge.rb b/app/models/badge.rb
new file mode 100644
index 000000000..f5dfbdc10
--- /dev/null
+++ b/app/models/badge.rb
@@ -0,0 +1,24 @@
+class Badge < ActiveRecord::Base
+  belongs_to :badge_type
+
+  validates :name, presence: true, uniqueness: true
+  validates :badge_type, presence: true
+end
+
+# == Schema Information
+#
+# Table name: badges
+#
+#  id            :integer          not null, primary key
+#  name          :string(255)      not null
+#  description   :text
+#  badge_type_id :integer          not null
+#  grant_count   :integer          default(0), not null
+#  created_at    :datetime
+#  updated_at    :datetime
+#
+# Indexes
+#
+#  index_badges_on_badge_type_id  (badge_type_id)
+#  index_badges_on_name           (name) UNIQUE
+#
diff --git a/app/models/badge_type.rb b/app/models/badge_type.rb
new file mode 100644
index 000000000..ede440185
--- /dev/null
+++ b/app/models/badge_type.rb
@@ -0,0 +1,21 @@
+class BadgeType < ActiveRecord::Base
+  has_many :badges
+
+  validates :name, presence: true, uniqueness: true
+  validates :color_hexcode, presence: true
+end
+
+# == Schema Information
+#
+# Table name: badge_types
+#
+#  id            :integer          not null, primary key
+#  name          :string(255)      not null
+#  color_hexcode :string(255)      not null
+#  created_at    :datetime
+#  updated_at    :datetime
+#
+# Indexes
+#
+#  index_badge_types_on_name  (name) UNIQUE
+#
diff --git a/app/models/user.rb b/app/models/user.rb
index d0ecc1b37..cd01e28b3 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -21,6 +21,7 @@ class User < ActiveRecord::Base
   has_many :user_open_ids, dependent: :destroy
   has_many :user_actions, dependent: :destroy
   has_many :post_actions, dependent: :destroy
+  has_many :user_badges, dependent: :destroy
   has_many :email_logs, dependent: :destroy
   has_many :post_timings
   has_many :topic_allowed_users, dependent: :destroy
diff --git a/app/models/user_badge.rb b/app/models/user_badge.rb
new file mode 100644
index 000000000..e62c3e0db
--- /dev/null
+++ b/app/models/user_badge.rb
@@ -0,0 +1,26 @@
+class UserBadge < ActiveRecord::Base
+  belongs_to :badge
+  belongs_to :user
+  belongs_to :granted_by, class_name: 'User'
+
+  validates :badge_id, presence: true, uniqueness: {scope: :user_id}
+  validates :user_id, presence: true
+  validates :granted_at, presence: true
+  validates :granted_by, presence: true
+end
+
+# == Schema Information
+#
+# Table name: user_badges
+#
+#  id            :integer          not null, primary key
+#  badge_id      :integer          not null
+#  user_id       :integer          not null
+#  granted_at    :datetime         not null
+#  granted_by_id :integer          not null
+#
+# Indexes
+#
+#  index_user_badges_on_badge_id_and_user_id  (badge_id,user_id) UNIQUE
+#  index_user_badges_on_user_id               (user_id)
+#
diff --git a/app/serializers/badge_serializer.rb b/app/serializers/badge_serializer.rb
new file mode 100644
index 000000000..05173cc30
--- /dev/null
+++ b/app/serializers/badge_serializer.rb
@@ -0,0 +1,5 @@
+class BadgeSerializer < ApplicationSerializer
+  attributes :id, :name, :description
+
+  has_one :badge_type
+end
diff --git a/app/serializers/badge_type_serializer.rb b/app/serializers/badge_type_serializer.rb
new file mode 100644
index 000000000..bd6ed272d
--- /dev/null
+++ b/app/serializers/badge_type_serializer.rb
@@ -0,0 +1,3 @@
+class BadgeTypeSerializer < ApplicationSerializer
+  attributes :id, :name, :color_hexcode
+end
diff --git a/app/serializers/user_badge_serializer.rb b/app/serializers/user_badge_serializer.rb
new file mode 100644
index 000000000..d91dfb916
--- /dev/null
+++ b/app/serializers/user_badge_serializer.rb
@@ -0,0 +1,6 @@
+class UserBadgeSerializer < ApplicationSerializer
+  attributes :id, :granted_at
+
+  has_one :badge
+  has_one :granted_by, serializer: BasicUserSerializer, root: :users
+end
diff --git a/app/services/badge_granter.rb b/app/services/badge_granter.rb
new file mode 100644
index 000000000..2fb74972d
--- /dev/null
+++ b/app/services/badge_granter.rb
@@ -0,0 +1,34 @@
+class BadgeGranter
+
+  def initialize(badge, user, opts={})
+    @badge, @user, @opts = badge, user, opts
+    @granted_by = opts[:granted_by] || Discourse.system_user
+  end
+
+  def self.grant(badge, user, opts={})
+    BadgeGranter.new(badge, user, opts).grant
+  end
+
+  def grant
+    return if @granted_by and !Guardian.new(@granted_by).can_grant_badges?(@user)
+
+    user_badge = nil
+
+    UserBadge.transaction do
+      user_badge = UserBadge.create!(badge: @badge, user: @user,
+                                     granted_by: @granted_by, granted_at: Time.now)
+
+      Badge.increment_counter 'grant_count', @badge.id
+    end
+
+    user_badge
+  end
+
+  def self.revoke(user_badge)
+    UserBadge.transaction do
+      user_badge.destroy!
+      Badge.decrement_counter 'grant_count', user_badge.badge.id
+    end
+  end
+
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 5e29bbcf0..9828b5b27 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1666,6 +1666,18 @@ en:
           legal: "Legal"
           uncategorized: 'Uncategorized'
 
+      badges:
+        title: Badges
+        new_badge: New Badge
+        new: New
+        name: Name
+        display_name: Display Name
+        description: Description
+        badge_type: Badge Type
+        save: Save
+        delete: Delete
+        delete_confirm: Are you sure you want to delete this badge?
+
     lightbox:
       download: "download"
 
@@ -1707,3 +1719,8 @@ en:
         mark_regular: '<b>m</b> then <b>r</b> Mark topic as regular'
         mark_tracking: '<b>m</b> then <b>t</b> Mark topic as tracking'
         mark_watching: '<b>m</b> then <b>w</b> Mark topic as watching'
+
+    badges:
+      example_badge:
+        name: Example Badge
+        description: This is a generic example badge.
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 5e68dce6e..5526a2b71 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -658,6 +658,8 @@ en:
     topics_per_period_in_top_page: "How many topics loaded on the top topics page"
     redirect_new_users_to_top_page_duration: "Number of days during which new users are automatically redirect to the top page"
 
+    enable_badges: "Enable the badge system (experimental)"
+
     allow_index_in_robots_txt: "Site should be indexed by search engines (update robots.txt)"
     email_domains_blacklist: "A pipe-delimited list of email domains that are not allowed. Example: mailinator.com|trashmail.net"
     email_domains_whitelist: "A pipe-delimited list of email domains that users may register with. WARNING: Users with email domains other than those listed will not be allowed."
@@ -1396,3 +1398,9 @@ en:
     message_to_blank: "message.to is blank"
     text_part_body_blank: "text_part.body is blank"
     body_blank: "body is blank"
+
+  badges:
+    types:
+      gold: Gold
+      silver: Silver
+      bronze: Bronze
diff --git a/config/routes.rb b/config/routes.rb
index c684da82f..40b9c6181 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -126,6 +126,12 @@ Discourse::Application.routes.draw do
       end
     end
 
+    resources :badges, constraints: AdminConstraint.new do
+      collection do
+        get "types" => "badges#badge_types"
+      end
+    end
+
     get "memory_stats"=> "diagnostics#memory_stats", constraints: AdminConstraint.new
 
   end # admin namespace
@@ -235,6 +241,8 @@ Discourse::Application.routes.draw do
   end
   resources :user_actions
 
+  resources :user_badges, only: [:index, :create, :destroy]
+
   # We've renamed popular to latest. If people access it we want a permanent redirect.
   get "popular" => "list#popular_redirect"
   get "popular/more" => "list#popular_redirect"
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 4758abd87..6eaff3599 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -75,6 +75,9 @@ basic:
     default: 50
   redirect_new_users_to_top_page_duration:
     default: 7
+  enable_badges:
+    client: true
+    default: false
 
 users:
   enable_sso:
diff --git a/db/fixtures/700_badge_types.rb b/db/fixtures/700_badge_types.rb
new file mode 100644
index 000000000..475817e85
--- /dev/null
+++ b/db/fixtures/700_badge_types.rb
@@ -0,0 +1,17 @@
+BadgeType.seed do |b|
+  b.id = 1
+  b.name = I18n.t('badges.types.gold')
+  b.color_hexcode = "ffd700"
+end
+
+BadgeType.seed do |b|
+  b.id = 2
+  b.name = I18n.t('badges.types.silver')
+  b.color_hexcode = "c0c0c0"
+end
+
+BadgeType.seed do |b|
+  b.id = 3
+  b.name = I18n.t('badges.types.bronze')
+  b.color_hexcode = "cd7f32"
+end
diff --git a/db/migrate/20140304200606_create_badge_types.rb b/db/migrate/20140304200606_create_badge_types.rb
new file mode 100644
index 000000000..e580c3a1e
--- /dev/null
+++ b/db/migrate/20140304200606_create_badge_types.rb
@@ -0,0 +1,12 @@
+class CreateBadgeTypes < ActiveRecord::Migration
+  def change
+    create_table :badge_types do |t|
+      t.string :name, null: false
+      t.string :color_hexcode, null: false
+
+      t.timestamps
+    end
+
+    add_index :badge_types, [:name], unique: true
+  end
+end
diff --git a/db/migrate/20140304201403_create_badges.rb b/db/migrate/20140304201403_create_badges.rb
new file mode 100644
index 000000000..1fbc9b52a
--- /dev/null
+++ b/db/migrate/20140304201403_create_badges.rb
@@ -0,0 +1,14 @@
+class CreateBadges < ActiveRecord::Migration
+  def change
+    create_table :badges do |t|
+      t.string :name, null: false
+      t.text :description
+      t.integer :badge_type_id, index: true, null: false
+      t.integer :grant_count, null: false, default: 0
+
+      t.timestamps
+    end
+
+    add_index :badges, [:name], unique: true
+  end
+end
diff --git a/db/migrate/20140305100909_create_user_badges.rb b/db/migrate/20140305100909_create_user_badges.rb
new file mode 100644
index 000000000..52afead5a
--- /dev/null
+++ b/db/migrate/20140305100909_create_user_badges.rb
@@ -0,0 +1,12 @@
+class CreateUserBadges < ActiveRecord::Migration
+  def change
+    create_table :user_badges do |t|
+      t.integer :badge_id, null: false
+      t.integer :user_id, index: true, null: false
+      t.datetime :granted_at, null: false
+      t.integer :granted_by_id, null: false
+    end
+
+    add_index :user_badges, [:badge_id, :user_id], unique: true
+  end
+end
diff --git a/lib/guardian.rb b/lib/guardian.rb
index 65e07b293..dbe0526d8 100644
--- a/lib/guardian.rb
+++ b/lib/guardian.rb
@@ -87,6 +87,7 @@ class Guardian
   alias :can_move_posts? :can_moderate?
   alias :can_see_flags? :can_moderate?
   alias :can_send_activation_email? :can_moderate?
+  alias :can_grant_badges? :can_moderate?
 
 
 
diff --git a/spec/controllers/admin/badges_controller_spec.rb b/spec/controllers/admin/badges_controller_spec.rb
new file mode 100644
index 000000000..5359c278f
--- /dev/null
+++ b/spec/controllers/admin/badges_controller_spec.rb
@@ -0,0 +1,60 @@
+require 'spec_helper'
+
+describe Admin::BadgesController do
+  it "is a subclass of AdminController" do
+    (Admin::BadgesController < Admin::AdminController).should be_true
+  end
+
+  context "while logged in as an admin" do
+    let!(:user) { log_in(:admin) }
+    let!(:badge) { Fabricate(:badge) }
+
+    context '.index' do
+      it 'returns success' do
+        xhr :get, :index
+        response.should be_success
+      end
+
+      it 'returns JSON' do
+        xhr :get, :index
+        ::JSON.parse(response.body)["badges"].should be_present
+      end
+    end
+
+    context '.badge_types' do
+      it 'returns success' do
+        xhr :get, :badge_types
+        response.should be_success
+      end
+
+      it 'returns JSON' do
+        xhr :get, :badge_types
+        ::JSON.parse(response.body)["badge_types"].should be_present
+      end
+    end
+
+    context '.destroy' do
+      it 'returns success' do
+        xhr :delete, :destroy, id: badge.id
+        response.should be_success
+      end
+
+      it 'deletes the badge' do
+        xhr :delete, :destroy, id: badge.id
+        Badge.where(id: badge.id).count.should eq(0)
+      end
+    end
+
+    context '.update' do
+      it 'returns success' do
+        xhr :put, :update, id: badge.id, name: "123456", badge_type_id: badge.badge_type_id
+        response.should be_success
+      end
+
+      it 'updates the badge' do
+        xhr :put, :update, id: badge.id, name: "123456", badge_type_id: badge.badge_type_id
+        badge.reload.name.should eq('123456')
+      end
+    end
+  end
+end
diff --git a/spec/controllers/user_badges_controller_spec.rb b/spec/controllers/user_badges_controller_spec.rb
new file mode 100644
index 000000000..4836df3ff
--- /dev/null
+++ b/spec/controllers/user_badges_controller_spec.rb
@@ -0,0 +1,79 @@
+require 'spec_helper'
+
+describe UserBadgesController do
+  let(:user) { Fabricate(:user) }
+  let(:badge) { Fabricate(:badge) }
+
+  context 'index' do
+    before do
+      @user_badge = BadgeGranter.grant(badge, user)
+    end
+
+    it 'requires username to be specified' do
+      expect { xhr :get, :index }.to raise_error
+    end
+
+    it 'returns the user\'s badges' do
+      xhr :get, :index, username: user.username
+
+      response.status.should == 200
+      parsed = JSON.parse(response.body)
+      parsed["user_badges"].length.should == 1
+    end
+  end
+
+  context 'create' do
+    it 'requires username to be specified' do
+      expect { xhr :post, :create, badge_id: badge.id }.to raise_error
+    end
+
+    it 'does not allow regular users to grant badges' do
+      log_in_user Fabricate(:user)
+      xhr :post, :create, badge_id: badge.id, username: user.username
+      response.status.should == 403
+    end
+
+    it 'grants badges from staff' do
+      admin = Fabricate(:admin)
+      log_in_user admin
+      xhr :post, :create, badge_id: badge.id, username: user.username
+      response.status.should == 200
+      user_badge = UserBadge.where(user: user, badge: badge).first
+      user_badge.should be_present
+      user_badge.granted_by.should eq(admin)
+    end
+
+    it 'does not grant badges from regular api calls' do
+      Fabricate(:api_key, user: user)
+      xhr :post, :create, badge_id: badge.id, username: user.username, api_key: user.api_key.key
+      response.status.should == 403
+    end
+
+    it 'grants badges from master api calls' do
+      api_key = Fabricate(:api_key)
+      xhr :post, :create, badge_id: badge.id, username: user.username, api_key: api_key.key
+      response.status.should == 200
+      user_badge = UserBadge.where(user: user, badge: badge).first
+      user_badge.should be_present
+      user_badge.granted_by.should eq(Discourse.system_user)
+    end
+  end
+
+  context 'destroy' do
+    before do
+      @user_badge = BadgeGranter.grant(badge, user)
+    end
+
+    it 'checks that the user is authorized to revoke a badge' do
+      xhr :delete, :destroy, id: @user_badge.id
+      response.status.should == 403
+    end
+
+    it 'revokes the badge' do
+      log_in :admin
+      xhr :delete, :destroy, id: @user_badge.id
+      response.status.should == 200
+      UserBadge.where(id: @user_badge.id).first.should be_nil
+    end
+  end
+end
diff --git a/spec/fabricators/badge_fabricator.rb b/spec/fabricators/badge_fabricator.rb
new file mode 100644
index 000000000..7ba52d8b4
--- /dev/null
+++ b/spec/fabricators/badge_fabricator.rb
@@ -0,0 +1,9 @@
+Fabricator(:badge_type) do
+  name { sequence(:name) {|i| "Silver #{i}" } }
+  color_hexcode "c0c0c0"
+end
+
+Fabricator(:badge) do
+  name { sequence(:name) {|i| "Badge #{i}" } }
+  badge_type
+end
diff --git a/spec/models/badge.rb b/spec/models/badge.rb
new file mode 100644
index 000000000..3309f9ce0
--- /dev/null
+++ b/spec/models/badge.rb
@@ -0,0 +1,17 @@
+require 'spec_helper'
+require_dependency 'badge'
+
+describe Badge do
+
+  it { should belong_to :badge_type }
+
+  context 'validations' do
+    before(:each) { Fabricate(:badge) }
+
+    it { should validate_presence_of :name }
+    it { should validate_presence_of :badge_type }
+    it { should validate_uniqueness_of :name }
+  end
+
+end
+
diff --git a/spec/models/badge_type.rb b/spec/models/badge_type.rb
new file mode 100644
index 000000000..bc716b857
--- /dev/null
+++ b/spec/models/badge_type.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+require_dependency 'badge_type'
+
+describe BadgeType do
+
+  it { should have_many :badges }
+
+  it { should validate_presence_of :name }
+  it { should validate_uniqueness_of :name }
+  it { should validate_presence_of :color_hexcode }
+
+end
diff --git a/spec/models/user_badge.rb b/spec/models/user_badge.rb
new file mode 100644
index 000000000..383db76a1
--- /dev/null
+++ b/spec/models/user_badge.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+require_dependency 'user_badge'
+
+describe UserBadge do
+
+  it { should belong_to :badge }
+  it { should belong_to :user }
+  it { should belong_to :granted_by }
+
+  context 'validations' do
+    before(:each) { BadgeGranter.grant(Fabricate(:badge), Fabricate(:user)) }
+
+    it { should validate_presence_of(:badge_id) }
+    it { should validate_presence_of(:user_id) }
+    it { should validate_presence_of(:granted_at) }
+    it { should validate_presence_of(:granted_by) }
+    it { should validate_uniqueness_of(:badge_id).scoped_to(:user_id) }
+  end
+
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 65554f4cd..b93370bfc 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -10,6 +10,7 @@ describe User do
   it { should have_many(:user_open_ids).dependent(:destroy) }
   it { should have_many(:user_actions).dependent(:destroy) }
   it { should have_many(:post_actions).dependent(:destroy) }
+  it { should have_many(:user_badges).dependent(:destroy) }
   it { should have_many(:email_logs).dependent(:destroy) }
   it { should have_many(:post_timings) }
   it { should have_many(:topic_allowed_users).dependent(:destroy) }
diff --git a/spec/services/badge_granter_spec.rb b/spec/services/badge_granter_spec.rb
new file mode 100644
index 000000000..1ffcde77f
--- /dev/null
+++ b/spec/services/badge_granter_spec.rb
@@ -0,0 +1,61 @@
+require 'spec_helper'
+
+describe BadgeGranter do
+
+  let(:badge) { Fabricate(:badge) }
+  let(:user) { Fabricate(:user) }
+
+  describe 'grant' do
+
+    it 'grants a badge' do
+      user_badge = BadgeGranter.grant(badge, user)
+      user_badge.should be_present
+    end
+
+    it 'sets granted_at' do
+      time = Time.zone.now
+      Timecop.freeze time
+
+      user_badge = BadgeGranter.grant(badge, user)
+      user_badge.granted_at.should eq(time)
+
+      Timecop.return
+    end
+
+    it 'sets granted_by if the option is present' do
+      admin = Fabricate(:admin)
+      user_badge = BadgeGranter.grant(badge, user, granted_by: admin)
+      user_badge.granted_by.should eq(admin)
+    end
+
+    it 'defaults granted_by to the system user' do
+      user_badge = BadgeGranter.grant(badge, user)
+      user_badge.granted_by_id.should eq(Discourse.system_user.id)
+    end
+
+    it 'does not allow a regular user to grant badges' do
+      user_badge = BadgeGranter.grant(badge, user, granted_by: Fabricate(:user))
+      user_badge.should_not be_present
+    end
+
+    it 'increments grant_count on the badge' do
+      BadgeGranter.grant(badge, user)
+      badge.reload.grant_count.should eq(1)
+    end
+
+  end
+
+  describe 'revoke' do
+
+    let!(:user_badge) { BadgeGranter.grant(badge, user) }
+
+    it 'revokes the badge and decrements grant_count' do
+      badge.reload.grant_count.should eq(1)
+      BadgeGranter.revoke(user_badge)
+      UserBadge.where(user: user, badge: badge).first.should_not be_present
+      badge.reload.grant_count.should eq(0)
+    end
+
+  end
+
+end
diff --git a/test/javascripts/admin/controllers/admin_badges_controller_test.js b/test/javascripts/admin/controllers/admin_badges_controller_test.js
new file mode 100644
index 000000000..532c8cb36
--- /dev/null
+++ b/test/javascripts/admin/controllers/admin_badges_controller_test.js
@@ -0,0 +1,81 @@
+module("Discourse.AdminBadgesController");
+
+test("showDisplayName", function() {
+  var badge, controller;
+
+  badge = Discourse.Badge.create({name: "Test Badge"});
+  controller = testController(Discourse.AdminBadgesController, [badge]);
+  controller.send('selectBadge', badge);
+  ok(!controller.get('showDisplayName'), "does not show displayName when it is the same as the name");
+
+  this.stub(I18n, "t").returns("translated string");
+  badge = Discourse.Badge.create({name: "Test Badge"});
+  controller = testController(Discourse.AdminBadgesController, [badge]);
+  controller.send('selectBadge', badge);
+  ok(controller.get('showDisplayName'), "shows the displayName when it is different from the name");
+});
+
+test("canEditDescription", function() {
+  var badge, controller;
+
+  badge = Discourse.Badge.create({name: "Test Badge"});
+  controller = testController(Discourse.AdminBadgesController, [badge]);
+  controller.send('selectBadge', badge);
+  ok(controller.get('canEditDescription'), "allows editing description when a translation exists for the badge name");
+
+  this.stub(I18n, "t").returns("translated string");
+  badge = Discourse.Badge.create({name: "Test Badge"});
+  controller = testController(Discourse.AdminBadgesController, [badge]);
+  controller.send('selectBadge', badge);
+  ok(!controller.get('canEditDescription'), "shows the displayName when it is different from the name");
+});
+
+test("newBadge", function() {
+  var controller = testController(Discourse.AdminBadgesController, []);
+  controller.send('newBadge');
+  equal(controller.get('model.length'), 1, "adds a new badge to the list of badges");
+  equal(controller.get('model')[0], controller.get('selectedItem'), "the new badge is selected");
+});
+
+test("selectBadge", function() {
+  var badge = Discourse.Badge.create({name: "Test Badge"}),
+      controller = testController(Discourse.AdminBadgesController, [badge]);
+
+  controller.send('selectBadge', badge);
+  equal(controller.get('selectedItem'), badge, "the badge is selected");
+});
+
+test("save", function() {
+  var badge = Discourse.Badge.create({name: "Test Badge"}),
+      otherBadge = Discourse.Badge.create({name: "Other Badge"}),
+      controller = testController(Discourse.AdminBadgesController, [badge, otherBadge]);
+
+  controller.send('selectBadge', badge);
+  this.stub(badge, "save").returns(Ember.RSVP.resolve({}));
+  controller.send("save");
+  ok(badge.save.calledOnce, "called save on the badge");
+});
+
+test("destroy", function() {
+  var badge = Discourse.Badge.create({name: "Test Badge"}),
+      otherBadge = Discourse.Badge.create({name: "Other Badge"}),
+      controller = testController(Discourse.AdminBadgesController, [badge, otherBadge]);
+
+  this.stub(badge, 'destroy').returns(Ember.RSVP.resolve({}));
+
+  bootbox.confirm = function(text, yes, no, func) {
+    func(false);
+  };
+
+  controller.send('selectBadge', badge);
+  controller.send('destroy');
+  ok(!badge.destroy.calledOnce, "badge is not destroyed if they user clicks no");
+
+  bootbox.confirm = function(text, yes, no, func) {
+    func(true);
+  };
+
+  controller.send('selectBadge', badge);
+  controller.send('destroy');
+  ok(badge.destroy.calledOnce, "badge is destroyed if they user clicks yes");
+});
diff --git a/test/javascripts/models/badge_test.js b/test/javascripts/models/badge_test.js
new file mode 100644
index 000000000..b19f55e29
--- /dev/null
+++ b/test/javascripts/models/badge_test.js
@@ -0,0 +1,69 @@
+module("Discourse.Badge");
+
+test('newBadge', function() {
+  var badge1 = Discourse.Badge.create({name: "New Badge"}),
+      badge2 = Discourse.Badge.create({id: 1, name: "Old Badge"});
+  ok(badge1.get('newBadge'), "badges without ids are new");
+  ok(!badge2.get('newBadge'), "badges with ids are not new");
+});
+
+test('displayName', function() {
+  var badge1 = Discourse.Badge.create({id: 1, name: "Test Badge 1"});
+  equal(badge1.get('displayName'), "Test Badge 1", "falls back to the original name in the absence of a translation");
+
+  this.stub(I18n, "t").returnsArg(0);
+  var badge2 = Discourse.Badge.create({id: 2, name: "Test Badge 2"});
+  equal(badge2.get('displayName'), "badges.test_badge_2.name", "uses translation when available");
+});
+
+test('translatedDescription', function() {
+  var badge1 = Discourse.Badge.create({id: 1, name: "Test Badge 1"});
+  equal(badge1.get('translatedDescription'), null, "returns null when no translation exists");
+
+  var badge2 = Discourse.Badge.create({id: 2, name: "Test Badge 2"});
+  this.stub(I18n, "t").returns("description translation");
+  equal(badge2.get('translatedDescription'), "description translation", "users translated description");
+});
+
+test('createFromJson array', function() {
+  var badgesJson = {"badge_types":[{"id":6,"name":"Silver 1","color_hexcode":"#c0c0c0"}],"badges":[{"id":1126,"name":"Badge 1","description":null,"badge_type_id":6}]};
+
+  var badges = Discourse.Badge.createFromJson(badgesJson);
+
+  ok(Array.isArray(badges), "returns an array");
+  equal(badges[0].get('name'), "Badge 1", "badge details are set");
+  equal(badges[0].get('badge_type.name'), "Silver 1", "badge_type reference is set");
+});
+
+test('createFromJson single', function() {
+  var badgeJson = {"badge_types":[{"id":6,"name":"Silver 1","color_hexcode":"#c0c0c0"}],"badge":{"id":1126,"name":"Badge 1","description":null,"badge_type_id":6}};
+
+  var badge = Discourse.Badge.createFromJson(badgeJson);
+
+  ok(!Array.isArray(badge), "does not returns an array");
+});
+
+test('updateFromJson', function() {
+  var badgeJson = {"badge_types":[{"id":6,"name":"Silver 1","color_hexcode":"#c0c0c0"}],"badge":{"id":1126,"name":"Badge 1","description":null,"badge_type_id":6}};
+  var badge = Discourse.Badge.create({name: "Badge 1"});
+  badge.updateFromJson(badgeJson);
+  equal(badge.get('id'), 1126, "id is set");
+  equal(badge.get('badge_type.name'), "Silver 1", "badge_type reference is set");
+});
+
+test('save', function() {
+  this.stub(Discourse, 'ajax').returns(Ember.RSVP.resolve({}));
+  var badge = Discourse.Badge.create({name: "New Badge", description: "This is a new badge.", badge_type_id: 1});
+  badge.save();
+  ok(Discourse.ajax.calledOnce, "saved badge");
+});
+
+test('destroy', function() {
+  this.stub(Discourse, 'ajax');
+  var badge = Discourse.Badge.create({name: "New Badge", description: "This is a new badge.", badge_type_id: 1});
+  badge.destroy();
+  ok(!Discourse.ajax.calledOnce, "no AJAX call for a new badge");
+  badge.set('id', 3);
+  badge.destroy();
+  ok(Discourse.ajax.calledOnce, "AJAX call was made");
+});
diff --git a/test/javascripts/models/user_badge_test.js b/test/javascripts/models/user_badge_test.js
new file mode 100644
index 000000000..306549ad6
--- /dev/null
+++ b/test/javascripts/models/user_badge_test.js
@@ -0,0 +1,19 @@
+module("Discourse.UserBadge");
+
+test('createFromJson single', function() {
+  var json = {"badges":[{"id":874,"name":"Badge 2","description":null,"badge_type_id":7}],"badge_types":[{"id":7,"name":"Silver 2","color_hexcode":"#c0c0c0"}],"users":[{"id":13470,"username":"anne3","avatar_template":"//www.gravatar.com/avatar/a4151b1fd72089c54e2374565a87da7f.png?s={size}\u0026r=pg\u0026d=identicon"}],"user_badge":{"id":665,"granted_at":"2014-03-09T20:30:01.190-04:00","badge_id":874,"granted_by_id":13470}};
+
+  var userBadge = Discourse.UserBadge.createFromJson(json);
+  ok(!Array.isArray(userBadge), "does not return an array");
+  equal(userBadge.get('badge.name'), "Badge 2", "badge reference is set");
+  equal(userBadge.get('badge.badge_type.name'), "Silver 2", "badge.badge_type reference is set");
+  equal(userBadge.get('granted_by.username'), "anne3", "granted_by reference is set");
+});
+
+test('createFromJson array', function() {
+  var json = {"badges":[{"id":880,"name":"Badge 8","description":null,"badge_type_id":13}],"badge_types":[{"id":13,"name":"Silver 8","color_hexcode":"#c0c0c0"}],"users":[],"user_badges":[{"id":668,"granted_at":"2014-03-09T20:30:01.420-04:00","badge_id":880,"granted_by_id":null}]};
+
+  var userBadges = Discourse.UserBadge.createFromJson(json);
+  ok(Array.isArray(userBadges), "returns an array");
+  equal(userBadges[0].get('granted_by'), null, "granted_by reference is not set when null");
+});